From e818131f1d17fcbb853e5345ed3c72b3e7e7270b Mon Sep 17 00:00:00 2001 From: Aliaksandr Zianevich Date: Thu, 23 Jul 2026 03:33:00 +0300 Subject: [PATCH 1/2] Add Foundation v5 design spec for modular hard-cut rewrite. EOF Co-authored-by: Cursor --- ...7-23-mistruna-core-foundation-v5-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-mistruna-core-foundation-v5-design.md diff --git a/docs/superpowers/specs/2026-07-23-mistruna-core-foundation-v5-design.md b/docs/superpowers/specs/2026-07-23-mistruna-core-foundation-v5-design.md new file mode 100644 index 0000000..6dcc547 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-mistruna-core-foundation-v5-design.md @@ -0,0 +1,258 @@ +# Mistruna.Core Foundation v5 — Design Spec + +**Date:** 2026-07-23 +**Status:** Approved (brainstorm) +**Scope:** Foundation wave only (modular hard-cut rewrite) +**Out of scope:** Brand/docs site wave, consumer service migration PRs, source generators / deep platform features + +--- + +## 1. Goals + +Ship an open-source–grade **v5** of Mistruna.Core that millions of ASP.NET microservice developers can adopt: + +- Clear package boundaries (pay only for what you use) +- Modern .NET 10 / System.Text.Json baseline +- Aggressive removal of domain junk and legacy abstractions +- Hard-cut public API with a migration guide +- Essential platform capabilities: OpenTelemetry + HTTP resilience as opt-in packages + +Non-goals for this wave: visual rebrand, DocFX/site, migrating `user-service` / `currency-service` in-repo, MassTransit-class broker abstraction, Roslyn analyzers pack. + +--- + +## 2. Decisions (locked) + +| Topic | Choice | +|-------|--------| +| First milestone | Foundation (structure, API, deps, tests, DX) | +| Breaking changes | Hard cut (no compatibility shims) | +| Packaging | Thin core + opt-in packages | +| Package IDs | Keep `Mistruna.*` | +| Trim policy | Aggressive — universal microservice primitives only | +| Stack baseline | net10 host packages; Abstractions `net8.0;net10.0`; STJ; RabbitMQ.Client 7.x | +| New capabilities in Foundation | Observability + Resilience opt-in packages | +| Implementation approach | Package-first rewrite (no strangler meta-package) | + +--- + +## 3. Package architecture + +### 3.1 Packages + +| Package | TFM | Responsibility | +|---------|-----|----------------| +| `Mistruna.Core.Abstractions` | `net8.0;net10.0` | `Result`/`Error`, CQRS markers, entity/VO bases, persistence interfaces, domain events, pagination DTOs | +| `Mistruna.Core` | `net10.0` | MediatR registration, FluentValidation + logging pipeline behaviors, exception types, core DI | +| `Mistruna.Core.AspNetCore` | `net10.0` | ProblemDetails exception handling, health/versioning/CORS/JWT helpers, IP rate limiting | +| `Mistruna.Core.EfCore` | `net10.0` | `EfGenericRepository`, `EfUnitOfWork`, EF conventions helpers | +| `Mistruna.Core.Caching.Redis` | `net10.0` | Redis cache implementation + health check | +| `Mistruna.Core.Messaging.RabbitMq` | `net10.0` | Async RabbitMQ.Client 7 bus (rewrite) | +| `Mistruna.Core.Monetization` | `net10.0` | API key auth, plan authorization (402), tiered rate limits, idempotency, usage metering | +| `Mistruna.Core.Observability` | `net10.0` | OpenTelemetry setup hooks + MediatR Activity enrichment | +| `Mistruna.Core.Resilience` | `net10.0` | HttpClient resilience presets + optional marked-command MediatR retry | +| `Mistruna.Core.Testing` | `net10.0` | EF async test providers and consumer test helpers | + +No umbrella meta-package that references everything. + +### 3.2 Dependency graph + +``` +Mistruna.Core.Abstractions + ↑ + Mistruna.Core + ↑ + Mistruna.Core.AspNetCore + ↑ + EfCore / Caching.Redis / Messaging.RabbitMq / + Monetization / Observability / Resilience + ↑ + Mistruna.Core.Testing (refs Core + EfCore as needed) +``` + +### 3.3 Removed from SDK (not ported) + +- `Currency` enum, `CurrencyExtensions`, `CurrencyInfo` (domain; belongs in currency-service) +- `AuthResponse` and other app-specific DTOs currently in Contracts +- Newtonsoft.Json usage and package references +- Legacy sync `RabbitManager` / `IModel` pool design (replaced by new async API) +- BCL duplicate extension methods (`DistinctBy`, `ToHashSet`, `Append`/`Prepend` where BCL already provides them) +- Untested / unused DI `TryAdd*` / `Decorate` helpers (keep only if tests + sample prove need) +- Custom `ErrorResponse` JSON envelope (replaced by ProblemDetails) + +### 3.4 Kept (possibly relocated) + +- Value objects: `Email`, `PhoneNumber`, `Money`, `Address`, `DateRange` → Abstractions +- Specification + repository interfaces → Abstractions; EF implementations → EfCore +- Monetization feature set → dedicated package (cleaned `AddMistruna*` surface) + +--- + +## 4. Public API & DI surface + +### 4.1 Naming + +- Prefer `AddMistruna*` / `UseMistruna*` over generic `AddCore` / `UseCoreMiddlewares` +- Namespaces align with packages (e.g. `Mistruna.Core.DependencyInjection`, `Mistruna.Core.Abstractions.Results`) +- Former `Mistruna.Core.Contracts.*` becomes `Mistruna.Core.Abstractions.*` + +### 4.2 Canonical composition + +```csharp +builder.Services.AddMistrunaCore(opts => +{ + opts.RegisterAssemblies(typeof(Program).Assembly); + opts.AddValidation(); + opts.AddLoggingBehavior(); +}); + +builder.Services.AddMistrunaAspNetCore(); +builder.Services.AddMistrunaEfCore(); +builder.Services.AddMistrunaRedis(configuration); +builder.Services.AddMistrunaRabbitMq(configuration); +builder.Services.AddMistrunaObservability(configuration); +builder.Services.AddMistrunaResilience(); +// builder.Services.AddMistrunaMonetization(); // optional + +var app = builder.Build(); +app.UseMistrunaExceptionHandler(); +``` + +No single `UseMistruna()` that enables everything implicitly. + +### 4.3 CQRS + +- `ICommand`, `ICommand`, `IQuery` live in Abstractions +- `RequestKind` retained for pipeline targeting +- MediatR remains the mediator; Core does not fork MediatR APIs +- FluentValidation behavior maps failures to HTTP 400 ProblemDetails via exception handler + +### 4.4 Result vs exceptions + +- Expected domain failures → `Result` / `Result` +- HTTP/infrastructure boundary → typed exceptions and/or `Result`→ProblemDetails helpers in AspNetCore +- AspNetCore provides `ToProblemDetails()` / minimal-API result helpers + +### 4.5 Serialization & OpenAPI + +- System.Text.Json only +- OpenAPI: keep Swashbuckle on STJ for Foundation (re-evaluate OpenAPI.NET in a later wave if needed) +- JWT helpers stay in AspNetCore; API keys / plans stay in Monetization + +### 4.6 API quality rules + +- Public members require XML docs +- Untested public surface is deleted or made internal +- `InternalsVisibleTo` limited to Testing package + unit test assembly +- Prefer extension-method registration; middleware types not part of casual public surface unless needed for advanced scenarios (`EditorBrowsable` / docs) + +--- + +## 5. Observability + +Package: `Mistruna.Core.Observability` + +- `AddMistrunaObservability(IConfiguration)` wires OpenTelemetry tracing + metrics using standard ASP.NET / Http instrumentation packages +- Exporters are configuration-driven; do not hard-depend on a single vendor exporter in the library package graph beyond what is required to compile samples +- MediatR `ActivitySource` name: `mistruna.mediator` +- Activity tags: `request.type`, `request.kind` (`command`|`query`), outcome/error +- Exception handler includes `traceId` in ProblemDetails extensions + +--- + +## 6. Resilience + +Package: `Mistruna.Core.Resilience` + +- Built on `Microsoft.Extensions.Http.Resilience` / Polly v8 +- `AddMistrunaResilience()` registers named HttpClient pipeline presets: `Standard`, `Aggressive`, `Disable` +- Optional MediatR behavior retries only requests marked with `[Resilient]` or a marker interface — never global retry on all commands +- All behavior is opt-in and documented + +--- + +## 7. Messaging rewrite (RabbitMQ) + +Package: `Mistruna.Core.Messaging.RabbitMq` + +- RabbitMQ.Client **7.x**, async-first (`IConnection` / `IChannel`) +- STJ serialization +- Replace legacy `IRabbitManager` sync API with a small explicit bus surface (publish + consume registration) +- Unit-test topology/serialization without requiring a broker in CI; Testcontainers optional follow-up + +--- + +## 8. Samples (Foundation minimum) + +1. `samples/Mistruna.Core.Samples.BasicApi` — Core + AspNetCore + CQRS + ProblemDetails +2. `samples/Mistruna.Core.Samples.WorkerRabbit` — publish/consume +3. `samples/Mistruna.Core.Samples.ApiWithOtel` — Observability + Resilience HttpClient + +Full documentation site is Wave Brand, not Foundation. Foundation ships root README package table + `docs/migration-v5.md`. + +--- + +## 9. Testing + +- Single test project `tests/Mistruna.Core.Tests` with folder-per-package layout +- Required coverage areas: + - Result/Error + - Validation + logging behaviors + - Exception handler → ProblemDetails (status, errorCode, traceId) + - EfCore repository/UoW (InMemory or SQLite) + - Redis via fake/`IDistributedCache` (no mandatory Redis in CI) + - Rabbit serialization/topology unit tests + - Monetization middleware suite (port existing) + - Observability Activity tags + - Resilience HttpClient registration +- Testing package smoke-tested +- Keep `TreatWarningsAsErrors`; reduce blanket nullability `NoWarn` on public API +- Recommended: `PublicApiAnalyzers` baselines per packable project + +--- + +## 10. CI / Release / Versioning + +- CI: restore → `dotnet format --verify-no-changes` → build → test (coverage upload) → pack all packable projects +- Fail if packable projects lack required package metadata (README/license/icon as applicable) +- Release on tag `v5.0.0` (MinVer prefix `v`) pushes all nupkgs + snupkgs +- All modules share the same release-train version +- v4.x line: frozen on NuGet; README states migrate to v5 packages +- Dependabot retained; group Microsoft.* / OpenTelemetry.* where practical + +--- + +## 11. Migration path + +Mandatory doc: `docs/migration-v5.md` covering: + +1. Old → new package reference map +2. Namespace / API rename table (`AddCore` → `AddMistrunaCore`, etc.) +3. Newtonsoft → STJ +4. Currency removal guidance +5. Rabbit old manager → new async bus +6. `ErrorResponse` → ProblemDetails +7. Checklist for `user-service` / `currency-service` (executed as follow-up outside this Core Foundation merge) + +Consumer migrations are **not** part of the Foundation implementation merge in Mistruna.Core. + +--- + +## 12. Success criteria + +Foundation is done when: + +- [ ] Solution builds on .NET 10; all tests green +- [ ] All section 3.1 packages exist, pack, and appear in README table +- [ ] Currency / Newtonsoft / legacy Rabbit manager are gone +- [ ] BasicApi + WorkerRabbit + ApiWithOtel samples run +- [ ] `docs/migration-v5.md` complete +- [ ] Repository is tag-ready for `v5.0.0` + +--- + +## 13. Follow-up waves (not this spec) + +1. **Brand & Docs** — identity, docs site, contributor UX polish +2. **Platform+** — source generators, analyzers, deeper conventions +3. **Ecosystem** — migrate GitLab services to v5, community/release communication From 442981bb09c3c0a41548b445efd7a9fa643fdb56 Mon Sep 17 00:00:00 2001 From: Aliaksandr Zianevich Date: Thu, 23 Jul 2026 21:02:16 +0300 Subject: [PATCH 2/2] upd --- .github/workflows/ci.yml | 3 + BuildContracts.ps1 | 71 --- Directory.Build.props | 4 +- Directory.Packages.props | 48 ++ Mistruna.Core.sln | 202 +++++++- README.md | 203 +++----- .../Mistruna.Core.Samples.ApiWithOtel.csproj | 15 + .../Program.cs | 33 ++ .../appsettings.json | 15 + .../Counter/IncrementCounterCommand.cs | 2 +- .../Features/Ping/PingQuery.cs | 2 +- .../Mistruna.Core.Samples.BasicApi.csproj | 8 +- .../Mistruna.Core.Samples.BasicApi/Program.cs | 18 +- .../Mistruna.Core.Samples.WorkerRabbit.csproj | 17 + .../Program.cs | 20 + .../RabbitWorker.cs | 36 ++ .../appsettings.json | 13 + samples/README.md | 47 +- .../Cqrs/ICommand.cs | 10 + src/Mistruna.Core.Abstractions/Cqrs/IQuery.cs | 7 + .../Cqrs}/RequestKind.cs | 18 +- .../Entities/CommonValueObjects.cs | 261 ++++++++++ .../Entities/Entity.cs | 135 ++++++ .../Entities/ValueObject.cs | 54 +++ .../Errors/IValidationErrorProvider.cs | 12 + .../Mistruna.Core.Abstractions.csproj | 18 + .../Persistence/IAuditable.cs | 10 + .../Persistence/ICacheService.cs | 35 ++ .../Persistence/IDomainEvent.cs | 46 ++ .../Persistence/IEntity.cs | 12 + .../Persistence/IGenericRepository.cs | 46 ++ .../Persistence/ISpecification.cs | 80 ++++ .../Persistence/IUnitOfWork.cs | 32 ++ .../Responses/DeleteResponse.cs | 10 + .../Responses/ExistResponse.cs | 10 + .../Responses/IResponse.cs | 8 + .../Responses/ItemResponse.cs | 10 + .../Responses/PageView.cs | 32 ++ .../Responses/PageViewResponse.cs | 8 + .../Responses/PageViewResponseExtensions.cs | 19 +- .../Results/Error.cs | 53 +++ .../Results/ErrorType.cs | 20 + .../Results/Result.cs | 164 +++++++ .../JwtServiceCollectionExtensions.cs | 65 +++ .../MistrunaJwtAuthorizationOptions.cs | 26 + .../Cors/CorsServiceCollectionExtensions.cs | 73 +++ .../Cors/MistrunaCorsOptions.cs | 18 + .../ApplicationBuilderExtensions.cs | 12 + .../ServiceCollectionExtensions.cs | 17 + .../DbUpdateViolationDetector.cs | 44 ++ .../MistrunaExceptionHandler.cs | 110 +++++ .../HealthChecks/DatabaseHealthCheck.cs | 28 ++ .../HealthCheckServiceCollectionExtensions.cs | 36 ++ .../Mistruna.Core.AspNetCore.csproj | 29 ++ ...ateLimitingApplicationBuilderExtensions.cs | 12 + ...RateLimitingServiceCollectionExtensions.cs | 26 + .../MistrunaIpRateLimitOptions.cs | 12 + .../MistrunaIpRateLimitingMiddleware.cs | 104 ++++ .../Results/ProblemDetailsExtensions.cs | 33 ++ .../Mistruna.Core.Caching.Redis.csproj | 52 ++ .../RedisCacheService.cs | 9 +- .../RedisHealthCheck.cs | 5 +- .../ServiceCollectionExtensions.cs | 71 +++ .../Attributes/PrecisionAttribute.cs | 60 --- .../Base/Entities/CommonValueObjects.cs | 446 ------------------ .../Base/Entities/Entity.cs | 254 ---------- .../Base/Entities/ValueObject.cs | 112 ----- .../Base/Infrastructure/IAuditable.cs | 13 - .../Base/Infrastructure/ICacheService.cs | 111 ----- .../Base/Infrastructure/IDomainEvent.cs | 107 ----- .../Base/Infrastructure/IEntity.cs | 16 - .../Base/Infrastructure/IGenericRepository.cs | 118 ----- .../Base/Infrastructure/IResponse.cs | 10 - .../Base/Infrastructure/ISpecification.cs | 200 -------- .../Base/Infrastructure/IUnitOfWork.cs | 90 ---- .../Base/Responses/AuthResponse.cs | 19 - .../Base/Responses/DeleteResponse.cs | 13 - .../Base/Responses/ErrorResponse.cs | 22 - .../Base/Responses/ExistResponse.cs | 13 - .../Base/Responses/ItemResponse.cs | 16 - .../Base/Responses/PageView.cs | 65 --- .../Base/Responses/PageViewResponse.cs | 13 - .../Base/Responses/ValidationErrorResponse.cs | 22 - .../Base/Results/Error.cs | 130 ----- .../Base/Results/Result.cs | 264 ----------- .../Errors/IValidationErrorProvider.cs | 20 - .../Helpers/EnumDescriptionHelper.cs | 35 -- .../RabbitMq/Services/Interfaces/IBus.cs | 14 - .../Services/Interfaces/IRabbitManager.cs | 35 -- .../Mistruna.Core.Contracts.csproj | 31 -- .../Models/CurrencyInfo.cs | 14 - .../EfGenericRepository.cs | 51 +- .../EfUnitOfWork.cs | 24 +- .../Mistruna.Core.EfCore.csproj | 22 + .../ServiceCollectionExtensions.cs | 22 + .../SpecificationEvaluator.cs | 53 +++ .../ServiceCollectionExtensions.cs | 82 ++++ .../IMistrunaRabbitBus.cs | 23 + .../IMistrunaRabbitMessageHandler.cs | 17 + .../Internal/IRabbitPublisherChannel.cs | 13 + .../Internal/RabbitConnectionFactory.cs | 17 + .../Internal/RabbitConsumerHostedService.cs | 93 ++++ .../Internal/RabbitConsumerRegistration.cs | 10 + .../Internal/RabbitMessageSerializer.cs | 17 + .../Internal/RabbitPublisherChannel.cs | 69 +++ .../Mistruna.Core.Messaging.RabbitMq.csproj | 26 + .../RabbitBus.cs | 45 ++ .../RabbitMqOptions.cs | 25 + .../ApiKey/ApiKeyAuthenticationHandler.cs | 13 +- .../ApiKey/ApiKeyAuthenticationOptions.cs | 2 +- .../Authentication/ApiKey/ApiKeyClaimTypes.cs | 2 +- .../Authentication/ApiKey/ApiKeyDefaults.cs | 2 +- .../ApiKey/ApiKeyValidationResult.cs | 4 +- .../Authentication/ApiKey/IApiKeyValidator.cs | 2 +- .../Authorization/Plans/Plan.cs | 2 +- ...lanAuthorizationMiddlewareResultHandler.cs | 2 +- .../Authorization/Plans/PlanClaimTypes.cs | 2 +- .../Plans/RequiresPlanAttribute.cs | 2 +- .../Plans/RequiresPlanAuthorizationHandler.cs | 2 +- .../Plans/RequiresPlanPolicyProvider.cs | 2 +- .../Plans/RequiresPlanRequirement.cs | 2 +- .../ApplicationBuilderExtensions.cs} | 43 +- .../ServiceCollectionExtensions.cs} | 63 ++- .../Idempotency/IIdempotencyStore.cs | 2 +- .../Idempotency/IdempotencyMiddleware.cs | 7 +- .../Idempotency/IdempotencyOptions.cs | 2 +- .../Idempotency/IdempotentResponse.cs | 2 +- .../Idempotency/RedisIdempotencyStore.cs | 5 +- .../Metering/IUsageMeter.cs | 2 +- .../Metering/RedisUsageMeter.cs | 2 +- .../Metering/UsageMeteringMiddleware.cs | 4 +- .../Metering/UsageMeteringOptions.cs | 2 +- .../Mistruna.Core.Monetization.csproj | 19 + .../RateLimiting/Tiered/IQuotaStore.cs | 2 +- .../RateLimiting/Tiered/InMemoryQuotaStore.cs | 5 +- .../RateLimiting/Tiered/QuotaResult.cs | 2 +- .../RateLimiting/Tiered/RedisQuotaStore.cs | 2 +- .../Tiered/TieredRateLimitMiddleware.cs | 6 +- .../Tiered/TieredRateLimitOptions.cs | 2 +- .../OpenTelemetryMediatorBehavior.cs | 73 +++ .../ServiceCollectionExtensions.cs | 59 +++ .../Mistruna.Core.Observability.csproj | 23 + .../MistrunaMediatorTelemetry.cs | 12 + .../Behaviors/ResilientCommandBehavior.cs | 31 ++ .../HttpClientBuilderExtensions.cs | 45 ++ .../ServiceCollectionExtensions.cs | 53 +++ ...trunaHttpResiliencePipelineConfigurator.cs | 32 ++ .../Mistruna.Core.Resilience.csproj | 20 + .../MistrunaResiliencePresets.cs | 22 + .../ResilientAttribute.cs | 5 + .../EfCore/AsyncQueryable.cs | 40 ++ .../EfCore}/TestAsyncEnumerable.cs | 4 +- .../EfCore}/TestAsyncEnumerator.cs | 2 +- .../EfCore}/TestAsyncQueryProvider.cs | 4 +- .../Mistruna.Core.Testing.csproj | 24 + .../Results/ResultTestExtensions.cs | 51 ++ src/Mistruna.Core/Abstractions/ICommand.cs | 19 - src/Mistruna.Core/Abstractions/IQuery.cs | 13 - .../Persistence/SpecificationEvaluator.cs | 69 --- .../Behaviors/LoggingBehavior.cs | 67 +++ .../Behaviors/RequestValidationBehavior.cs | 36 ++ .../Behaviors/ResultValidationBehavior.cs | 61 +++ .../MistrunaCoreOptions.cs | 35 ++ .../ServiceCollectionExtensions.cs | 64 +++ src/Mistruna.Core/Enums/Currency.cs | 213 --------- .../Exceptions/ConflictException.cs | 2 +- .../Exceptions/ForbiddenAccessException.cs | 2 +- .../Exceptions/NotFoundException.cs | 4 +- .../ApplicationBuilderExtensions.cs | 76 --- .../Extensions/CollectionExtensions.cs | 171 ------- .../Extensions/CorsExtensions.cs | 78 --- .../Extensions/CurrencyExtensions.cs | 425 ----------------- ...HealthChecksServiceCollectionExtensions.cs | 50 -- .../PersistenceServiceCollectionExtensions.cs | 25 - .../RedisServiceCollectionExtensions.cs | 75 --- .../Extensions/ServiceCollectionExtensions.cs | 186 -------- .../Filters/ConfigureSwaggerOptions.cs | 89 ---- src/Mistruna.Core/Filters/LoggingBehavior.cs | 79 ---- .../Filters/PathPrefixInsertDocumentFilter.cs | 22 - .../Filters/RequestValidationBehavior.cs | 74 --- .../Filters/ResultValidationBehavior.cs | 76 --- .../Filters/SwaggerDefaultValues.cs | 56 --- .../HealthChecks/DatabaseHealthCheck.cs | 49 -- .../Helpers/ExpressionHelpers.cs | 2 +- .../ApiVersioningConfiguration.cs | 28 -- .../Core/Configurations/CorsConfiguration.cs | 80 ---- .../Authorization/AuthOptions.cs | 8 - .../Core/JwtAuth/JwtAuthorizationOptions.cs | 18 - .../Core/JwtAuth/JwtTokenProvider.cs | 60 --- .../Core/JwtAuth/Policies/Policies.cs | 8 - .../Microservices/Core/JwtAuth/RoleType.cs | 11 - .../ServiceCollectionExtension.cs | 120 ----- .../ServerMiddleware/ServiceMiddleware.cs | 59 --- .../Attributes/RabbitQueryAttribute.cs | 17 - .../Configurations/RabbitMqConfiguration.cs | 25 - .../RabbitServiceCollectionExtensions.cs | 47 -- .../RabbitMqEndpointBinder/RabbitMqWrapper.cs | 74 --- .../Implementations/EndpointConfiguration.cs | 37 -- .../Implementations/EndpointsConfiguration.cs | 41 -- .../Implementations/RabbitMQHostedService.cs | 30 -- .../Services/Implementations/RabbitManager.cs | 148 ------ .../RabbitModelPooledObjectPolicy.cs | 48 -- .../Interfaces/IEndpointConfiguration.cs | 21 - .../Interfaces/IEndpointsConfiguration.cs | 10 - .../ExceptionHandlingMiddleware.cs | 150 ------ ...sForeignKeyViolationExceptionMiddleware.cs | 57 --- ...eConstraintViolationExceptionMiddleware.cs | 55 --- src/Mistruna.Core/Mistruna.Core.csproj | 19 +- .../RateLimiting/RateLimitOptions.cs | 31 -- .../RateLimiting/RateLimitingMiddleware.cs | 125 ----- .../Abstractions/RequestKindTests.cs | 2 +- .../AspNetCore/ExceptionHandlerTests.cs | 35 ++ .../Jwt/JwtAuthorizationRegistrationTests.cs | 38 +- .../Caching/Redis/RedisCacheServiceTests.cs | 101 ++++ .../Caching/Redis/RedisPackageTests.cs | 28 ++ .../Caching/Redis/RedisRegistrationTests.cs | 71 +++ .../Core/RequestValidationBehaviorTests.cs | 45 ++ test/Mistruna.Core.Tests/EntityTests.cs | 2 +- test/Mistruna.Core.Tests/ExtensionTests.cs | 154 ------ .../Messaging/RabbitMqTests.cs | 123 +++++ .../Mistruna.Core.Tests.csproj | 33 +- .../ApiKeyAuthenticationHandlerTests.cs | 14 +- .../Idempotency/IdempotencyMiddlewareTests.cs | 9 +- .../Metering/UsageMeteringMiddlewareTests.cs | 6 +- .../MonetizationRegistrationTests.cs | 65 +++ ...thorizationMiddlewareResultHandlerTests.cs | 4 +- .../RequiresPlanAuthorizationHandlerTests.cs | 4 +- .../InMemoryQuotaStoreTests.cs | 4 +- .../TieredRateLimitMiddlewareTests.cs | 8 +- .../ObservabilityRegistrationTests.cs | 32 ++ .../OpenTelemetryMediatorBehaviorTests.cs | 97 ++++ .../PageViewResponseExtensionsTests.cs | 2 +- .../Persistence/PersistenceContractTests.cs | 22 +- .../Persistence/UnitOfWorkTests.cs | 59 ++- .../Resilience/ResilienceRegistrationTests.cs | 55 +++ .../ResilientCommandBehaviorTests.cs | 76 +++ test/Mistruna.Core.Tests/ResultTests.cs | 2 +- .../Testing/TestAsyncProviderTests.cs | 88 ++++ 238 files changed, 4811 insertions(+), 6045 deletions(-) delete mode 100644 BuildContracts.ps1 create mode 100644 Directory.Packages.props create mode 100644 samples/Mistruna.Core.Samples.ApiWithOtel/Mistruna.Core.Samples.ApiWithOtel.csproj create mode 100644 samples/Mistruna.Core.Samples.ApiWithOtel/Program.cs create mode 100644 samples/Mistruna.Core.Samples.ApiWithOtel/appsettings.json create mode 100644 samples/Mistruna.Core.Samples.WorkerRabbit/Mistruna.Core.Samples.WorkerRabbit.csproj create mode 100644 samples/Mistruna.Core.Samples.WorkerRabbit/Program.cs create mode 100644 samples/Mistruna.Core.Samples.WorkerRabbit/RabbitWorker.cs create mode 100644 samples/Mistruna.Core.Samples.WorkerRabbit/appsettings.json create mode 100644 src/Mistruna.Core.Abstractions/Cqrs/ICommand.cs create mode 100644 src/Mistruna.Core.Abstractions/Cqrs/IQuery.cs rename src/{Mistruna.Core/Abstractions => Mistruna.Core.Abstractions/Cqrs}/RequestKind.cs (61%) create mode 100644 src/Mistruna.Core.Abstractions/Entities/CommonValueObjects.cs create mode 100644 src/Mistruna.Core.Abstractions/Entities/Entity.cs create mode 100644 src/Mistruna.Core.Abstractions/Entities/ValueObject.cs create mode 100644 src/Mistruna.Core.Abstractions/Errors/IValidationErrorProvider.cs create mode 100644 src/Mistruna.Core.Abstractions/Mistruna.Core.Abstractions.csproj create mode 100644 src/Mistruna.Core.Abstractions/Persistence/IAuditable.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/ICacheService.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/IDomainEvent.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/IEntity.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/IGenericRepository.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/ISpecification.cs create mode 100644 src/Mistruna.Core.Abstractions/Persistence/IUnitOfWork.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/DeleteResponse.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/ExistResponse.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/IResponse.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/ItemResponse.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/PageView.cs create mode 100644 src/Mistruna.Core.Abstractions/Responses/PageViewResponse.cs rename src/{Mistruna.Core.Contracts/Base => Mistruna.Core.Abstractions}/Responses/PageViewResponseExtensions.cs (60%) create mode 100644 src/Mistruna.Core.Abstractions/Results/Error.cs create mode 100644 src/Mistruna.Core.Abstractions/Results/ErrorType.cs create mode 100644 src/Mistruna.Core.Abstractions/Results/Result.cs create mode 100644 src/Mistruna.Core.AspNetCore/Authentication/JwtServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/Authentication/MistrunaJwtAuthorizationOptions.cs create mode 100644 src/Mistruna.Core.AspNetCore/Cors/CorsServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/Cors/MistrunaCorsOptions.cs create mode 100644 src/Mistruna.Core.AspNetCore/DependencyInjection/ApplicationBuilderExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/ExceptionHandling/DbUpdateViolationDetector.cs create mode 100644 src/Mistruna.Core.AspNetCore/ExceptionHandling/MistrunaExceptionHandler.cs create mode 100644 src/Mistruna.Core.AspNetCore/HealthChecks/DatabaseHealthCheck.cs create mode 100644 src/Mistruna.Core.AspNetCore/HealthChecks/HealthCheckServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/Mistruna.Core.AspNetCore.csproj create mode 100644 src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingApplicationBuilderExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitOptions.cs create mode 100644 src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitingMiddleware.cs create mode 100644 src/Mistruna.Core.AspNetCore/Results/ProblemDetailsExtensions.cs create mode 100644 src/Mistruna.Core.Caching.Redis/Mistruna.Core.Caching.Redis.csproj rename src/{Mistruna.Core/Caching => Mistruna.Core.Caching.Redis}/RedisCacheService.cs (95%) rename src/{Mistruna.Core/HealthChecks => Mistruna.Core.Caching.Redis}/RedisHealthCheck.cs (81%) create mode 100644 src/Mistruna.Core.Caching.Redis/ServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core.Contracts/Attributes/PrecisionAttribute.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Entities/CommonValueObjects.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Entities/Entity.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Entities/ValueObject.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IAuditable.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/ICacheService.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IDomainEvent.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IEntity.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IGenericRepository.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/ISpecification.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Infrastructure/IUnitOfWork.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/AuthResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/DeleteResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/ErrorResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/ExistResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/ItemResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/PageView.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/PageViewResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Responses/ValidationErrorResponse.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Results/Error.cs delete mode 100644 src/Mistruna.Core.Contracts/Base/Results/Result.cs delete mode 100644 src/Mistruna.Core.Contracts/Errors/IValidationErrorProvider.cs delete mode 100644 src/Mistruna.Core.Contracts/Helpers/EnumDescriptionHelper.cs delete mode 100644 src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IBus.cs delete mode 100644 src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IRabbitManager.cs delete mode 100644 src/Mistruna.Core.Contracts/Mistruna.Core.Contracts.csproj delete mode 100644 src/Mistruna.Core.Contracts/Models/CurrencyInfo.cs rename src/{Mistruna.Core/Base/Persistence => Mistruna.Core.EfCore}/EfGenericRepository.cs (69%) rename src/{Mistruna.Core/Base/Persistence => Mistruna.Core.EfCore}/EfUnitOfWork.cs (88%) create mode 100644 src/Mistruna.Core.EfCore/Mistruna.Core.EfCore.csproj create mode 100644 src/Mistruna.Core.EfCore/ServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.EfCore/SpecificationEvaluator.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitBus.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitMessageHandler.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/IRabbitPublisherChannel.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConnectionFactory.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerHostedService.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerRegistration.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitMessageSerializer.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitPublisherChannel.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/Mistruna.Core.Messaging.RabbitMq.csproj create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/RabbitBus.cs create mode 100644 src/Mistruna.Core.Messaging.RabbitMq/RabbitMqOptions.cs rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs (88%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs (92%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/ApiKeyClaimTypes.cs (88%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/ApiKeyDefaults.cs (86%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/ApiKeyValidationResult.cs (88%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authentication/ApiKey/IApiKeyValidator.cs (87%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/Plan.cs (84%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs (97%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/PlanClaimTypes.cs (90%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/RequiresPlanAttribute.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/RequiresPlanAuthorizationHandler.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/RequiresPlanPolicyProvider.cs (96%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Authorization/Plans/RequiresPlanRequirement.cs (85%) rename src/{Mistruna.Core/Extensions/MonetizationApplicationBuilderExtensions.cs => Mistruna.Core.Monetization/DependencyInjection/ApplicationBuilderExtensions.cs} (65%) rename src/{Mistruna.Core/Extensions/MonetizationServiceCollectionExtensions.cs => Mistruna.Core.Monetization/DependencyInjection/ServiceCollectionExtensions.cs} (58%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Idempotency/IIdempotencyStore.cs (89%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Idempotency/IdempotencyMiddleware.cs (91%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Idempotency/IdempotencyOptions.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Idempotency/IdempotentResponse.cs (89%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Idempotency/RedisIdempotencyStore.cs (92%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Metering/IUsageMeter.cs (90%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Metering/RedisUsageMeter.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Metering/UsageMeteringMiddleware.cs (92%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/Metering/UsageMeteringOptions.cs (89%) create mode 100644 src/Mistruna.Core.Monetization/Mistruna.Core.Monetization.csproj rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/IQuotaStore.cs (92%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/InMemoryQuotaStore.cs (88%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/QuotaResult.cs (85%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/RedisQuotaStore.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/TieredRateLimitMiddleware.cs (94%) rename src/{Mistruna.Core => Mistruna.Core.Monetization}/RateLimiting/Tiered/TieredRateLimitOptions.cs (91%) create mode 100644 src/Mistruna.Core.Observability/Behaviors/OpenTelemetryMediatorBehavior.cs create mode 100644 src/Mistruna.Core.Observability/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.Observability/Mistruna.Core.Observability.csproj create mode 100644 src/Mistruna.Core.Observability/MistrunaMediatorTelemetry.cs create mode 100644 src/Mistruna.Core.Resilience/Behaviors/ResilientCommandBehavior.cs create mode 100644 src/Mistruna.Core.Resilience/DependencyInjection/HttpClientBuilderExtensions.cs create mode 100644 src/Mistruna.Core.Resilience/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Mistruna.Core.Resilience/Internal/MistrunaHttpResiliencePipelineConfigurator.cs create mode 100644 src/Mistruna.Core.Resilience/Mistruna.Core.Resilience.csproj create mode 100644 src/Mistruna.Core.Resilience/MistrunaResiliencePresets.cs create mode 100644 src/Mistruna.Core.Resilience/ResilientAttribute.cs create mode 100644 src/Mistruna.Core.Testing/EfCore/AsyncQueryable.cs rename src/{Mistruna.Core/Providers => Mistruna.Core.Testing/EfCore}/TestAsyncEnumerable.cs (92%) rename src/{Mistruna.Core/Providers => Mistruna.Core.Testing/EfCore}/TestAsyncEnumerator.cs (93%) rename src/{Mistruna.Core/Providers => Mistruna.Core.Testing/EfCore}/TestAsyncQueryProvider.cs (95%) create mode 100644 src/Mistruna.Core.Testing/Mistruna.Core.Testing.csproj create mode 100644 src/Mistruna.Core.Testing/Results/ResultTestExtensions.cs delete mode 100644 src/Mistruna.Core/Abstractions/ICommand.cs delete mode 100644 src/Mistruna.Core/Abstractions/IQuery.cs delete mode 100644 src/Mistruna.Core/Base/Persistence/SpecificationEvaluator.cs create mode 100644 src/Mistruna.Core/Behaviors/LoggingBehavior.cs create mode 100644 src/Mistruna.Core/Behaviors/RequestValidationBehavior.cs create mode 100644 src/Mistruna.Core/Behaviors/ResultValidationBehavior.cs create mode 100644 src/Mistruna.Core/DependencyInjection/MistrunaCoreOptions.cs create mode 100644 src/Mistruna.Core/DependencyInjection/ServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Enums/Currency.cs delete mode 100644 src/Mistruna.Core/Extensions/ApplicationBuilderExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/CollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/CorsExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/CurrencyExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/HealthChecksServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/PersistenceServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/RedisServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Filters/ConfigureSwaggerOptions.cs delete mode 100644 src/Mistruna.Core/Filters/LoggingBehavior.cs delete mode 100644 src/Mistruna.Core/Filters/PathPrefixInsertDocumentFilter.cs delete mode 100644 src/Mistruna.Core/Filters/RequestValidationBehavior.cs delete mode 100644 src/Mistruna.Core/Filters/ResultValidationBehavior.cs delete mode 100644 src/Mistruna.Core/Filters/SwaggerDefaultValues.cs delete mode 100644 src/Mistruna.Core/HealthChecks/DatabaseHealthCheck.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/Configurations/ApiVersioningConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/Configurations/CorsConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/Infrastructure/Authorization/AuthOptions.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/JwtAuth/JwtAuthorizationOptions.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/JwtAuth/JwtTokenProvider.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/JwtAuth/Policies/Policies.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/JwtAuth/RoleType.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceCollectionExtension.cs delete mode 100644 src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceMiddleware.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Attributes/RabbitQueryAttribute.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Configurations/RabbitMqConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Extensions/RabbitServiceCollectionExtensions.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/RabbitMqEndpointBinder/RabbitMqWrapper.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointsConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitMQHostedService.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitManager.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitModelPooledObjectPolicy.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointConfiguration.cs delete mode 100644 src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointsConfiguration.cs delete mode 100644 src/Mistruna.Core/Middlewares/ExceptionHandlingMiddleware.cs delete mode 100644 src/Mistruna.Core/Middlewares/IsForeignKeyViolationExceptionMiddleware.cs delete mode 100644 src/Mistruna.Core/Middlewares/IsUniqueConstraintViolationExceptionMiddleware.cs delete mode 100644 src/Mistruna.Core/RateLimiting/RateLimitOptions.cs delete mode 100644 src/Mistruna.Core/RateLimiting/RateLimitingMiddleware.cs create mode 100644 test/Mistruna.Core.Tests/AspNetCore/ExceptionHandlerTests.cs create mode 100644 test/Mistruna.Core.Tests/Caching/Redis/RedisCacheServiceTests.cs create mode 100644 test/Mistruna.Core.Tests/Caching/Redis/RedisPackageTests.cs create mode 100644 test/Mistruna.Core.Tests/Caching/Redis/RedisRegistrationTests.cs create mode 100644 test/Mistruna.Core.Tests/Core/RequestValidationBehaviorTests.cs create mode 100644 test/Mistruna.Core.Tests/Messaging/RabbitMqTests.cs rename test/Mistruna.Core.Tests/{Authentication => Monetization}/ApiKey/ApiKeyAuthenticationHandlerTests.cs (93%) rename test/Mistruna.Core.Tests/{ => Monetization}/Idempotency/IdempotencyMiddlewareTests.cs (94%) rename test/Mistruna.Core.Tests/{ => Monetization}/Metering/UsageMeteringMiddlewareTests.cs (95%) create mode 100644 test/Mistruna.Core.Tests/Monetization/MonetizationRegistrationTests.cs rename test/Mistruna.Core.Tests/{Authorization => Monetization}/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs (95%) rename test/Mistruna.Core.Tests/{Authorization => Monetization}/Plans/RequiresPlanAuthorizationHandlerTests.cs (95%) rename test/Mistruna.Core.Tests/{RateLimiting/Tiered => Monetization/TieredRateLimiting}/InMemoryQuotaStoreTests.cs (92%) rename test/Mistruna.Core.Tests/{RateLimiting/Tiered => Monetization/TieredRateLimiting}/TieredRateLimitMiddlewareTests.cs (93%) create mode 100644 test/Mistruna.Core.Tests/Observability/ObservabilityRegistrationTests.cs create mode 100644 test/Mistruna.Core.Tests/Observability/OpenTelemetryMediatorBehaviorTests.cs create mode 100644 test/Mistruna.Core.Tests/Resilience/ResilienceRegistrationTests.cs create mode 100644 test/Mistruna.Core.Tests/Resilience/ResilientCommandBehaviorTests.cs create mode 100644 test/Mistruna.Core.Tests/Testing/TestAsyncProviderTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc88fbf..d64baa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Verify formatting + run: dotnet format --verify-no-changes --no-restore + - name: Build run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore diff --git a/BuildContracts.ps1 b/BuildContracts.ps1 deleted file mode 100644 index 986742f..0000000 --- a/BuildContracts.ps1 +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Build only the Contracts package - -.DESCRIPTION - This script builds only the Mistruna.Core.Contracts project. - Useful for scenarios where you only need the interfaces without the full implementation. - -.PARAMETER Configuration - Build configuration (Debug or Release). Default is Release. - -.EXAMPLE - ./BuildContracts.ps1 - -.EXAMPLE - ./BuildContracts.ps1 -Configuration Debug -#> - -[CmdletBinding()] -param( - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Release' -) - -$ErrorActionPreference = 'Stop' - -$artifacts = Join-Path $PSScriptRoot 'artifacts' -$contractsProject = Join-Path $PSScriptRoot 'src\Mistruna.Core.Contracts\Mistruna.Core.Contracts.csproj' - -Write-Host "Building Mistruna.Core.Contracts..." -ForegroundColor Cyan -Write-Host "Configuration: $Configuration" -ForegroundColor Gray - -# Clean artifacts directory -if (Test-Path $artifacts) { - Write-Host "Cleaning artifacts directory..." -ForegroundColor Yellow - Remove-Item $artifacts -Force -Recurse -} - -# Restore packages -Write-Host "Restoring packages..." -ForegroundColor Yellow -dotnet restore $contractsProject - -if ($LASTEXITCODE -ne 0) { - throw "Restore failed with exit code $LASTEXITCODE" -} - -# Build project -Write-Host "Building project..." -ForegroundColor Yellow -dotnet build $contractsProject --configuration $Configuration --no-restore - -if ($LASTEXITCODE -ne 0) { - throw "Build failed with exit code $LASTEXITCODE" -} - -# Create package -Write-Host "Creating NuGet package..." -ForegroundColor Yellow -dotnet pack $contractsProject --configuration $Configuration --no-build --output $artifacts - -if ($LASTEXITCODE -ne 0) { - throw "Pack failed with exit code $LASTEXITCODE" -} - -Write-Host "" -Write-Host "Build completed successfully!" -ForegroundColor Green -Write-Host "Package is available in: $artifacts" -ForegroundColor Gray - -# List created package -Get-ChildItem $artifacts -Filter "*.nupkg" | ForEach-Object { - Write-Host " - $($_.Name)" -ForegroundColor Cyan -} diff --git a/Directory.Build.props b/Directory.Build.props index 1341057..96d9b38 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -58,11 +58,11 @@ - + - + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..d415df2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,48 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mistruna.Core.sln b/Mistruna.Core.sln index 0294005..8963c10 100644 --- a/Mistruna.Core.sln +++ b/Mistruna.Core.sln @@ -15,9 +15,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitattributes = .gitattributes .gitignore = .gitignore Build.ps1 = Build.ps1 - BuildContracts.ps1 = BuildContracts.ps1 CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props GenerateKey.ps1 = GenerateKey.ps1 LICENSE = LICENSE NuGet.Config = NuGet.Config @@ -28,42 +28,228 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core", "src\Mistruna.Core\Mistruna.Core.csproj", "{C16A8634-36D6-4353-B246-D6F43E49D92A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Contracts", "src\Mistruna.Core.Contracts\Mistruna.Core.Contracts.csproj", "{D27B9745-47E7-4464-C357-E7A54F5AE83B}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Tests", "test\Mistruna.Core.Tests\Mistruna.Core.Tests.csproj", "{E38CA856-58F8-5575-D468-F8A65A6BF94C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Samples.BasicApi", "samples\Mistruna.Core.Samples.BasicApi\Mistruna.Core.Samples.BasicApi.csproj", "{F49DB967-69F9-6686-E579-A9176A7CAA5D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Abstractions", "src\Mistruna.Core.Abstractions\Mistruna.Core.Abstractions.csproj", "{1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.AspNetCore", "src\Mistruna.Core.AspNetCore\Mistruna.Core.AspNetCore.csproj", "{F4CFB8FD-3423-426B-A4D6-A49C96DEB733}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.EfCore", "src\Mistruna.Core.EfCore\Mistruna.Core.EfCore.csproj", "{142806A6-80F0-4283-975D-0EBA72AD2A7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Caching.Redis", "src\Mistruna.Core.Caching.Redis\Mistruna.Core.Caching.Redis.csproj", "{4D0F006F-ED0E-443A-9BD5-F1427BBD696C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Messaging.RabbitMq", "src\Mistruna.Core.Messaging.RabbitMq\Mistruna.Core.Messaging.RabbitMq.csproj", "{E47A7412-3775-4A11-B867-D2857C0C18C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Monetization", "src\Mistruna.Core.Monetization\Mistruna.Core.Monetization.csproj", "{37AE8F50-445E-4D6C-A933-501AE168F59A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Observability", "src\Mistruna.Core.Observability\Mistruna.Core.Observability.csproj", "{CEDB658E-54A5-476D-A30A-88EE694FD782}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Resilience", "src\Mistruna.Core.Resilience\Mistruna.Core.Resilience.csproj", "{BBA18D08-124E-4786-8EC9-AC30899B9FC5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Testing", "src\Mistruna.Core.Testing\Mistruna.Core.Testing.csproj", "{3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Samples.WorkerRabbit", "samples\Mistruna.Core.Samples.WorkerRabbit\Mistruna.Core.Samples.WorkerRabbit.csproj", "{C52F80ED-8895-4CBB-B74B-63AE44D38DCF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mistruna.Core.Samples.ApiWithOtel", "samples\Mistruna.Core.Samples.ApiWithOtel\Mistruna.Core.Samples.ApiWithOtel.csproj", "{1249DB07-16DF-4C65-9984-93CD5D62AAAF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|x64.Build.0 = Debug|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Debug|x86.Build.0 = Debug|Any CPU {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|Any CPU.ActiveCfg = Release|Any CPU {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|Any CPU.Build.0 = Release|Any CPU - {D27B9745-47E7-4464-C357-E7A54F5AE83B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D27B9745-47E7-4464-C357-E7A54F5AE83B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D27B9745-47E7-4464-C357-E7A54F5AE83B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D27B9745-47E7-4464-C357-E7A54F5AE83B}.Release|Any CPU.Build.0 = Release|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|x64.ActiveCfg = Release|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|x64.Build.0 = Release|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|x86.ActiveCfg = Release|Any CPU + {C16A8634-36D6-4353-B246-D6F43E49D92A}.Release|x86.Build.0 = Release|Any CPU {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|x64.ActiveCfg = Debug|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|x64.Build.0 = Debug|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|x86.ActiveCfg = Debug|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Debug|x86.Build.0 = Debug|Any CPU {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|Any CPU.Build.0 = Release|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|x64.ActiveCfg = Release|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|x64.Build.0 = Release|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|x86.ActiveCfg = Release|Any CPU + {E38CA856-58F8-5575-D468-F8A65A6BF94C}.Release|x86.Build.0 = Release|Any CPU {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|x64.Build.0 = Debug|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Debug|x86.Build.0 = Debug|Any CPU {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|Any CPU.ActiveCfg = Release|Any CPU {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|Any CPU.Build.0 = Release|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|x64.ActiveCfg = Release|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|x64.Build.0 = Release|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|x86.ActiveCfg = Release|Any CPU + {F49DB967-69F9-6686-E579-A9176A7CAA5D}.Release|x86.Build.0 = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|x64.Build.0 = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Debug|x86.Build.0 = Debug|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|Any CPU.Build.0 = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|x64.ActiveCfg = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|x64.Build.0 = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|x86.ActiveCfg = Release|Any CPU + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE}.Release|x86.Build.0 = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|x64.Build.0 = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Debug|x86.Build.0 = Debug|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|Any CPU.Build.0 = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|x64.ActiveCfg = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|x64.Build.0 = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|x86.ActiveCfg = Release|Any CPU + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733}.Release|x86.Build.0 = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|x64.Build.0 = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Debug|x86.Build.0 = Debug|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|Any CPU.Build.0 = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|x64.ActiveCfg = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|x64.Build.0 = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|x86.ActiveCfg = Release|Any CPU + {142806A6-80F0-4283-975D-0EBA72AD2A7E}.Release|x86.Build.0 = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|x64.Build.0 = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Debug|x86.Build.0 = Debug|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|Any CPU.Build.0 = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|x64.ActiveCfg = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|x64.Build.0 = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|x86.ActiveCfg = Release|Any CPU + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C}.Release|x86.Build.0 = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|x64.Build.0 = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Debug|x86.Build.0 = Debug|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|Any CPU.Build.0 = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|x64.ActiveCfg = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|x64.Build.0 = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|x86.ActiveCfg = Release|Any CPU + {E47A7412-3775-4A11-B867-D2857C0C18C5}.Release|x86.Build.0 = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|x64.ActiveCfg = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|x64.Build.0 = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|x86.ActiveCfg = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Debug|x86.Build.0 = Debug|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|Any CPU.Build.0 = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|x64.ActiveCfg = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|x64.Build.0 = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|x86.ActiveCfg = Release|Any CPU + {37AE8F50-445E-4D6C-A933-501AE168F59A}.Release|x86.Build.0 = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|x64.Build.0 = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Debug|x86.Build.0 = Debug|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|Any CPU.Build.0 = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|x64.ActiveCfg = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|x64.Build.0 = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|x86.ActiveCfg = Release|Any CPU + {CEDB658E-54A5-476D-A30A-88EE694FD782}.Release|x86.Build.0 = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|x64.ActiveCfg = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|x64.Build.0 = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|x86.ActiveCfg = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Debug|x86.Build.0 = Debug|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|Any CPU.Build.0 = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|x64.ActiveCfg = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|x64.Build.0 = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|x86.ActiveCfg = Release|Any CPU + {BBA18D08-124E-4786-8EC9-AC30899B9FC5}.Release|x86.Build.0 = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|x64.Build.0 = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Debug|x86.Build.0 = Debug|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|Any CPU.Build.0 = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|x64.ActiveCfg = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|x64.Build.0 = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|x86.ActiveCfg = Release|Any CPU + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61}.Release|x86.Build.0 = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|x64.Build.0 = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Debug|x86.Build.0 = Debug|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|Any CPU.Build.0 = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|x64.ActiveCfg = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|x64.Build.0 = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|x86.ActiveCfg = Release|Any CPU + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF}.Release|x86.Build.0 = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|x64.ActiveCfg = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|x64.Build.0 = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Debug|x86.Build.0 = Debug|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|Any CPU.Build.0 = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|x64.ActiveCfg = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|x64.Build.0 = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|x86.ActiveCfg = Release|Any CPU + {1249DB07-16DF-4C65-9984-93CD5D62AAAF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {C16A8634-36D6-4353-B246-D6F43E49D92A} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} - {D27B9745-47E7-4464-C357-E7A54F5AE83B} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} {E38CA856-58F8-5575-D468-F8A65A6BF94C} = {4F2F9A59-7E1F-42A4-B2C0-9D3B6C5E8FA0} {F49DB967-69F9-6686-E579-A9176A7CAA5D} = {5A3B0A6A-8F2A-43A5-C3D1-AE4C7D6F9AB1} + {1A47CB5B-C85A-4902-BEB0-62A5B30EF0EE} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {F4CFB8FD-3423-426B-A4D6-A49C96DEB733} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {142806A6-80F0-4283-975D-0EBA72AD2A7E} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {4D0F006F-ED0E-443A-9BD5-F1427BBD696C} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {E47A7412-3775-4A11-B867-D2857C0C18C5} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {37AE8F50-445E-4D6C-A933-501AE168F59A} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {CEDB658E-54A5-476D-A30A-88EE694FD782} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {BBA18D08-124E-4786-8EC9-AC30899B9FC5} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {3B2A82FC-357E-47E5-BC1D-A1C8D1D8EA61} = {3E1E8F48-6D0E-4F3E-A1B9-8C2A5B4D7E9F} + {C52F80ED-8895-4CBB-B74B-63AE44D38DCF} = {5A3B0A6A-8F2A-43A5-C3D1-AE4C7D6F9AB1} + {1249DB07-16DF-4C65-9984-93CD5D62AAAF} = {5A3B0A6A-8F2A-43A5-C3D1-AE4C7D6F9AB1} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 1102492..fc54309 100644 --- a/README.md +++ b/README.md @@ -2,180 +2,109 @@ [![CI](https://github.com/mistruna/Mistruna.Core/workflows/CI/badge.svg)](https://github.com/mistruna/Mistruna.Core/actions?query=workflow%3ACI) [![NuGet](https://img.shields.io/nuget/v/Mistruna.Core.svg)](https://www.nuget.org/packages/Mistruna.Core) -[![NuGet Downloads](https://img.shields.io/nuget/dt/Mistruna.Core.svg)](https://www.nuget.org/packages/Mistruna.Core) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**Production-oriented SDK for ASP.NET Core microservices** — MediatR CQRS, validation, JWT, RabbitMQ, Redis, health checks, rate limiting, idempotency, usage metering, and consistent API primitives. +Production-oriented .NET building blocks for ASP.NET Core microservices: CQRS, validation, ProblemDetails, persistence, Redis, RabbitMQ, observability, resilience, and optional monetization primitives. -[Quick start](#quick-start) • [Packages](#packages) • [CQRS](#cqrs-icommand--iquery) • [Features](#features) • [Samples](samples/) - ---- +Mistruna.Core v5 is a package-based redesign. Version 4 is frozen and receives no new features; see the [v5 migration guide](docs/migration-v5.md). ## Packages -| Package | Target | Purpose | -|---------|--------|---------| -| `Mistruna.Core` | .NET 10 | Full SDK implementation | -| `Mistruna.Core.Contracts` | .NET Standard 2.0 | Interfaces, entities, `Result`, specifications | +| Package | TFM | Responsibility | +|---------|-----|----------------| +| `Mistruna.Core.Abstractions` | `net8.0;net10.0` | Results, CQRS markers, entity/value-object bases, persistence contracts, domain events, and pagination DTOs | +| `Mistruna.Core` | `net10.0` | MediatR registration, validation and logging behaviors, exceptions, and core DI | +| `Mistruna.Core.AspNetCore` | `net10.0` | ProblemDetails, health, versioning, CORS, JWT, and IP rate limiting | +| `Mistruna.Core.EfCore` | `net10.0` | EF repositories, unit of work, and conventions | +| `Mistruna.Core.Caching.Redis` | `net10.0` | Redis caching and health check | +| `Mistruna.Core.Messaging.RabbitMq` | `net10.0` | RabbitMQ.Client 7 asynchronous bus | +| `Mistruna.Core.Monetization` | `net10.0` | API keys, plan authorization, tiered rate limits, idempotency, and metering | +| `Mistruna.Core.Observability` | `net10.0` | OpenTelemetry registration and MediatR activity enrichment | +| `Mistruna.Core.Resilience` | `net10.0` | HttpClient resilience presets and marked-command retry behavior | +| `Mistruna.Core.Testing` | `net10.0` | EF async providers and consumer test helpers | + +There is no umbrella meta-package. Add only the packages your service uses: ```bash dotnet add package Mistruna.Core -# optional contracts-only package for shared libraries: -dotnet add package Mistruna.Core.Contracts +dotnet add package Mistruna.Core.AspNetCore +dotnet add package Mistruna.Core.EfCore ``` ---- - ## Quick start ```csharp -// Program.cs -builder.Services.AddCore(typeof(Program).Assembly); -builder.Services.AddCoreHealthChecks(); - -var app = builder.Build(); -app.UseCoreMiddlewares(); -app.MapHealthChecks("/health"); -``` - -Define handlers with CQRS markers: - -```csharp -public sealed record GetUserQuery(Guid Id) : IQuery; +using Mistruna.Core.DependencyInjection; +using Mistruna.Core.AspNetCore.DependencyInjection; +using Mistruna.Core.EfCore; -public sealed class CreateUserCommand : ICommand +builder.Services.AddMistrunaCore(options => { - public string Email { get; init; } = string.Empty; -} -``` - -Run the sample API: + options.RegisterAssemblies(typeof(Program).Assembly); + options.AddValidation(); + options.AddLoggingBehavior(); +}); +builder.Services.AddMistrunaAspNetCore(); +builder.Services.AddMistrunaEfCore(); -```bash -dotnet run --project samples/Mistruna.Core.Samples.BasicApi +var app = builder.Build(); +app.UseMistrunaExceptionHandler(); ``` ---- - -## CQRS (`ICommand` / `IQuery`) - -Mistruna.Core ships MediatR marker interfaces in `Mistruna.Core.Abstractions`: - -- **`ICommand` / `ICommand`** — write operations (create, update, delete, auth flows) -- **`IQuery`** — read-only operations - -Use `RequestKind` in custom pipeline behaviors: +Optional integrations compose independently: ```csharp -if (!RequestKind.IsCommand(typeof(TRequest))) - return await next(); +builder.Services.AddMistrunaRedis(builder.Configuration); +builder.Services.AddMistrunaRabbitMq(builder.Configuration); +builder.Services.AddMistrunaObservability(builder.Configuration); +builder.Services.AddMistrunaResilience(); +builder.Services.AddMistrunaMonetization(); + +app.UseMistrunaTieredRateLimiting(); +app.UseMistrunaIdempotency(); +app.UseMistrunaUsageMetering(); ``` -This keeps audit logging, metering, and side effects off read paths. - ---- - -## Features - -### Application core -- MediatR registration (`AddCore`) -- FluentValidation pipeline (`RequestValidationBehavior`) -- Structured request logging (`LoggingBehavior`) -- Centralized exception middleware (`UseCoreMiddlewares`) -- Custom exceptions mapped to HTTP status codes - -### Domain & data (Contracts + Core) -- `Result` / `Error` functional errors -- Entity base types (`Entity`, `AuditableEntity`, `SoftDeletableEntity`) -- Value objects (`Email`, `PhoneNumber`, `Money`, `Address`, `DateRange`) -- Specification pattern + `EfGenericRepository` / `EfUnitOfWork` -- Domain event contracts - -### Infrastructure -- JWT authentication helpers -- RabbitMQ integration -- Redis caching (`AddRedisCaching`) -- Health checks (`AddCoreHealthChecks`, `AddDatabaseHealthCheck`, `AddRedisHealthCheck`) -- IP rate limiting (`UseRateLimiting`) - -### Monetization primitives (optional, compose as needed) -- API key authentication (`AddMistrunaApiKeyAuthentication`) -- Plan-based authorization with HTTP 402 (`AddMistrunaPlanAuthorization`, `[RequiresPlan]`) -- Tiered rate limiting (`AddMistrunaTieredRateLimiting`, `UseTieredRateLimiting`) -- Idempotency middleware (`AddMistrunaIdempotency`, `UseIdempotency`) -- Usage metering (`AddMistrunaUsageMetering`, `UseUsageMetering`) - -### Testing helpers -- `TestAsyncQueryProvider` / `TestAsyncEnumerable` — async EF Core repository tests without a database - ---- - -## Exception handling - -`UseCoreMiddlewares()` maps exceptions to HTTP responses: - -| Exception | Status | -|-----------|--------| -| `ValidationException` | 400 | -| `NotFoundException` | 404 | -| `UnauthorizedAccessException` | 401 | -| `ForbiddenAccessException` | 403 | -| `ConflictException` | 409 | -| `TimeoutException` | 408 | - ---- - -## Health checks +Define requests with the markers from `Mistruna.Core.Abstractions`: ```csharp -builder.Services - .AddCoreHealthChecks(builder => builder - .AddDatabaseHealthCheck() - .AddRedisHealthCheck()); +public sealed record GetUserQuery(Guid Id) : IQuery; +public sealed record CreateUserCommand(string Email) : ICommand; ``` ---- +## Foundation features -## Building from source +- System.Text.Json-only serialization and RFC 7807 ProblemDetails +- MediatR command/query markers with opt-in validation and logging behaviors +- EF Core repository, specification, and unit-of-work implementations +- Redis caching and health checks +- RabbitMQ.Client 7 async publishing via `IMistrunaRabbitBus.PublishAsync` +- OpenTelemetry tracing, metrics, and MediatR activity enrichment +- HttpClient resilience presets and optional marked-command retries +- API key authentication, plan authorization, rate limits, idempotency, and metering +- EF async query providers and result assertions for consumer tests -**Prerequisites:** [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +## Samples ```powershell -./Build.ps1 # build, test, pack -./BuildContracts.ps1 # contracts package only -./Push.ps1 -ApiKey "your-api-key" +dotnet run --project samples/Mistruna.Core.Samples.BasicApi +dotnet run --project samples/Mistruna.Core.Samples.WorkerRabbit +dotnet run --project samples/Mistruna.Core.Samples.ApiWithOtel ``` -Place the NuGet icon at `assets/logo/mistruna_128x128.png` before publishing packages (optional for local builds). +The RabbitMQ sample requires a broker. See [samples/README.md](samples/README.md) for configuration. ---- +## Build -## Project structure +Prerequisite: [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -``` -Mistruna.Core/ -├── src/ -│ ├── Mistruna.Core/ # Main SDK -│ │ ├── Abstractions/ # ICommand, IQuery, RequestKind -│ │ ├── Authentication/ # API key auth -│ │ ├── Authorization/ # Plan-based authorization -│ │ ├── Base/ # EF repository helpers -│ │ ├── Extensions/ # DI and middleware registration -│ │ ├── Filters/ # MediatR pipeline behaviors -│ │ ├── HealthChecks/ # DB / Redis health checks -│ │ ├── Idempotency/ # Idempotency middleware -│ │ ├── Metering/ # Usage metering -│ │ ├── Microservices/ # JWT, RabbitMQ, Swagger -│ │ ├── Middlewares/ # Exception handling -│ │ ├── Providers/ # EF async test helpers -│ │ └── RateLimiting/ # IP + tiered rate limits -│ └── Mistruna.Core.Contracts/ # Shared contracts -├── test/Mistruna.Core.Tests/ -├── samples/Mistruna.Core.Samples.BasicApi/ -└── assets/logo/ # Package icon (add before publish) +```powershell +dotnet format --verify-no-changes +dotnet test +dotnet pack --configuration Release --output ./artifacts ``` ---- +`./Build.ps1` runs restore, build, test, and pack. Packages are written to `artifacts/`. ## Contributing diff --git a/samples/Mistruna.Core.Samples.ApiWithOtel/Mistruna.Core.Samples.ApiWithOtel.csproj b/samples/Mistruna.Core.Samples.ApiWithOtel/Mistruna.Core.Samples.ApiWithOtel.csproj new file mode 100644 index 0000000..41bc374 --- /dev/null +++ b/samples/Mistruna.Core.Samples.ApiWithOtel/Mistruna.Core.Samples.ApiWithOtel.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + false + false + + + + + + + + + diff --git a/samples/Mistruna.Core.Samples.ApiWithOtel/Program.cs b/samples/Mistruna.Core.Samples.ApiWithOtel/Program.cs new file mode 100644 index 0000000..19b7918 --- /dev/null +++ b/samples/Mistruna.Core.Samples.ApiWithOtel/Program.cs @@ -0,0 +1,33 @@ +using Mistruna.Core.DependencyInjection; +using Mistruna.Core.Observability.DependencyInjection; +using Mistruna.Core.Resilience.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMistrunaCore(options => + options.RegisterAssemblies(typeof(Program).Assembly)); +builder.Services.AddMistrunaObservability(builder.Configuration); +builder.Services.AddMistrunaResilience(); +builder.Services + .AddHttpClient("upstream", client => + client.BaseAddress = new Uri("https://example.com")) + .AddMistrunaResilienceHandler(); + +var app = builder.Build(); + +app.MapGet("/", () => Results.Ok(new +{ + message = "Mistruna observability and resilience sample", + upstreamEndpoint = "/upstream" +})); + +app.MapGet("/upstream", async ( + IHttpClientFactory httpClientFactory, + CancellationToken cancellationToken) => +{ + var client = httpClientFactory.CreateClient("upstream"); + var content = await client.GetStringAsync("/", cancellationToken); + return Results.Text(content, "text/html"); +}); + +await app.RunAsync(); diff --git a/samples/Mistruna.Core.Samples.ApiWithOtel/appsettings.json b/samples/Mistruna.Core.Samples.ApiWithOtel/appsettings.json new file mode 100644 index 0000000..ecd0de3 --- /dev/null +++ b/samples/Mistruna.Core.Samples.ApiWithOtel/appsettings.json @@ -0,0 +1,15 @@ +{ + "OpenTelemetry": { + "ServiceName": "Mistruna.Core.Samples.ApiWithOtel", + "Otlp": { + "Endpoint": "http://localhost:4317" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Mistruna.Core.Samples.BasicApi/Features/Counter/IncrementCounterCommand.cs b/samples/Mistruna.Core.Samples.BasicApi/Features/Counter/IncrementCounterCommand.cs index a93a1b2..4b28927 100644 --- a/samples/Mistruna.Core.Samples.BasicApi/Features/Counter/IncrementCounterCommand.cs +++ b/samples/Mistruna.Core.Samples.BasicApi/Features/Counter/IncrementCounterCommand.cs @@ -1,5 +1,5 @@ using MediatR; -using Mistruna.Core.Abstractions; +using Mistruna.Core.Abstractions.Cqrs; namespace Mistruna.Core.Samples.BasicApi.Features.Counter; diff --git a/samples/Mistruna.Core.Samples.BasicApi/Features/Ping/PingQuery.cs b/samples/Mistruna.Core.Samples.BasicApi/Features/Ping/PingQuery.cs index c4ff4bb..a4d3466 100644 --- a/samples/Mistruna.Core.Samples.BasicApi/Features/Ping/PingQuery.cs +++ b/samples/Mistruna.Core.Samples.BasicApi/Features/Ping/PingQuery.cs @@ -1,5 +1,5 @@ using MediatR; -using Mistruna.Core.Abstractions; +using Mistruna.Core.Abstractions.Cqrs; namespace Mistruna.Core.Samples.BasicApi.Features.Ping; diff --git a/samples/Mistruna.Core.Samples.BasicApi/Mistruna.Core.Samples.BasicApi.csproj b/samples/Mistruna.Core.Samples.BasicApi/Mistruna.Core.Samples.BasicApi.csproj index 230fcb9..e57861a 100644 --- a/samples/Mistruna.Core.Samples.BasicApi/Mistruna.Core.Samples.BasicApi.csproj +++ b/samples/Mistruna.Core.Samples.BasicApi/Mistruna.Core.Samples.BasicApi.csproj @@ -14,13 +14,15 @@ + + - - - + + + diff --git a/samples/Mistruna.Core.Samples.BasicApi/Program.cs b/samples/Mistruna.Core.Samples.BasicApi/Program.cs index c78f8bc..1fdae51 100644 --- a/samples/Mistruna.Core.Samples.BasicApi/Program.cs +++ b/samples/Mistruna.Core.Samples.BasicApi/Program.cs @@ -1,5 +1,7 @@ using MediatR; -using Mistruna.Core.Extensions; +using Mistruna.Core.AspNetCore.DependencyInjection; +using Mistruna.Core.AspNetCore.HealthChecks; +using Mistruna.Core.DependencyInjection; using Mistruna.Core.Samples.BasicApi.Features.Counter; using Mistruna.Core.Samples.BasicApi.Features.Ping; @@ -14,14 +16,20 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - builder.Services.AddCore(typeof(Program).Assembly); - builder.Services.AddCoreHealthChecks(); + builder.Services.AddMistrunaCore(options => + { + options.RegisterAssemblies(typeof(Program).Assembly); + options.AddValidation(); + options.AddLoggingBehavior(); + }); + builder.Services.AddMistrunaAspNetCore(); + builder.Services.AddMistrunaHealthChecks(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); - app.UseCoreMiddlewares(); + app.UseMistrunaExceptionHandler(); if (app.Environment.IsDevelopment()) { @@ -46,7 +54,7 @@ public static void Main(string[] args) app.MapGet("/errors/not-found", () => { - throw new Exceptions.NotFoundException("Sample resource not found"); + throw new Exceptions.NotFoundException("Sample resource not found", "X.NotFound"); }); app.Run(); diff --git a/samples/Mistruna.Core.Samples.WorkerRabbit/Mistruna.Core.Samples.WorkerRabbit.csproj b/samples/Mistruna.Core.Samples.WorkerRabbit/Mistruna.Core.Samples.WorkerRabbit.csproj new file mode 100644 index 0000000..fa90b4e --- /dev/null +++ b/samples/Mistruna.Core.Samples.WorkerRabbit/Mistruna.Core.Samples.WorkerRabbit.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + false + false + + + + + + + + + + + diff --git a/samples/Mistruna.Core.Samples.WorkerRabbit/Program.cs b/samples/Mistruna.Core.Samples.WorkerRabbit/Program.cs new file mode 100644 index 0000000..9d6c6b6 --- /dev/null +++ b/samples/Mistruna.Core.Samples.WorkerRabbit/Program.cs @@ -0,0 +1,20 @@ +using Mistruna.Core.Messaging.RabbitMq.DependencyInjection; +using Mistruna.Core.Samples.WorkerRabbit; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddMistrunaRabbitMq(builder.Configuration); + +if (builder.Configuration.GetValue("Mistruna:RabbitMq:EnableConsumer", false)) +{ + builder.Services.AddMistrunaRabbitConsumer( + queue: "sample.orders", + exchange: "sample", + routingKey: "orders.submitted"); +} +else +{ + builder.Services.AddHostedService(); +} + +await builder.Build().RunAsync(); diff --git a/samples/Mistruna.Core.Samples.WorkerRabbit/RabbitWorker.cs b/samples/Mistruna.Core.Samples.WorkerRabbit/RabbitWorker.cs new file mode 100644 index 0000000..b35e038 --- /dev/null +++ b/samples/Mistruna.Core.Samples.WorkerRabbit/RabbitWorker.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Options; +using Mistruna.Core.Messaging.RabbitMq; + +namespace Mistruna.Core.Samples.WorkerRabbit; + +public sealed record OrderSubmitted(Guid OrderId, DateTimeOffset SubmittedAt); + +public sealed class OrderSubmittedHandler(ILogger logger) + : IMistrunaRabbitMessageHandler +{ + public Task HandleAsync( + OrderSubmitted message, + CancellationToken cancellationToken = default) + { + logger.LogInformation( + "Received order {OrderId} submitted at {SubmittedAt}.", + message.OrderId, + message.SubmittedAt); + return Task.CompletedTask; + } +} + +public sealed class ConfigurationReadyWorker( + IOptions options, + ILogger logger) + : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation( + "RabbitMQ configuration loaded for {HostName}:{Port}. Set Mistruna__RabbitMq__EnableConsumer=true to connect.", + options.Value.HostName, + options.Value.Port); + return Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + } +} diff --git a/samples/Mistruna.Core.Samples.WorkerRabbit/appsettings.json b/samples/Mistruna.Core.Samples.WorkerRabbit/appsettings.json new file mode 100644 index 0000000..d8bd346 --- /dev/null +++ b/samples/Mistruna.Core.Samples.WorkerRabbit/appsettings.json @@ -0,0 +1,13 @@ +{ + "Mistruna": { + "RabbitMq": { + "EnableConsumer": false, + "HostName": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "ClientProvidedName": "Mistruna.Core.Samples.WorkerRabbit" + } + } +} diff --git a/samples/README.md b/samples/README.md index 59e0f41..6435389 100644 --- a/samples/README.md +++ b/samples/README.md @@ -6,10 +6,10 @@ Sample projects demonstrating Mistruna.Core usage. Demonstrates: -- `AddCore()` — MediatR + FluentValidation + pipeline behaviors +- `AddMistrunaCore()` — MediatR + FluentValidation + pipeline behaviors - CQRS markers — `IQuery` and `ICommand` -- `UseCoreMiddlewares()` — centralized exception handling -- `AddCoreHealthChecks()` — `/health` endpoint +- `UseMistrunaExceptionHandler()` — ProblemDetails exception handling +- `AddMistrunaHealthChecks()` — `/health` endpoint - Swagger in Development ### Run @@ -23,3 +23,44 @@ Try: - `GET /ping/World` - `POST /counter/increment?step=2` - `GET /errors/not-found` + +## WorkerRabbit + +Demonstrates: + +- `AddMistrunaRabbitMq()` — binds `Mistruna:RabbitMq` configuration +- `AddMistrunaRabbitConsumer()` — typed message handling +- Broker-free startup by default + +### Run + +```bash +dotnet run --project samples/Mistruna.Core.Samples.WorkerRabbit +``` + +The default configuration binds RabbitMQ options without opening a connection. To +start the consumer, provide a reachable broker and set +`Mistruna__RabbitMq__EnableConsumer=true`. Other settings use the same environment +variable pattern, for example `Mistruna__RabbitMq__HostName`. + +## ApiWithOtel + +Demonstrates: + +- `AddMistrunaObservability()` — ASP.NET Core, HTTP client, and MediatR instrumentation +- `AddMistrunaResilience()` — shared resilience registrations +- `AddMistrunaResilienceHandler()` — a resilient outbound HTTP client + +### Run + +```bash +dotnet run --project samples/Mistruna.Core.Samples.ApiWithOtel +``` + +Try: + +- `GET /` +- `GET /upstream` + +The sample produces OpenTelemetry telemetry. Add the exporter package and exporter +configuration required by your deployment, such as OTLP, in the host application. diff --git a/src/Mistruna.Core.Abstractions/Cqrs/ICommand.cs b/src/Mistruna.Core.Abstractions/Cqrs/ICommand.cs new file mode 100644 index 0000000..f2fa7ea --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Cqrs/ICommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace Mistruna.Core.Abstractions.Cqrs; + +/// Marks a command that does not return a value. +public interface ICommand : IRequest; + +/// Marks a command that returns a response. +/// The response type. +public interface ICommand : IRequest; diff --git a/src/Mistruna.Core.Abstractions/Cqrs/IQuery.cs b/src/Mistruna.Core.Abstractions/Cqrs/IQuery.cs new file mode 100644 index 0000000..76fd5a0 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Cqrs/IQuery.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace Mistruna.Core.Abstractions.Cqrs; + +/// Marks a read-only query that returns a response. +/// The response type. +public interface IQuery : IRequest; diff --git a/src/Mistruna.Core/Abstractions/RequestKind.cs b/src/Mistruna.Core.Abstractions/Cqrs/RequestKind.cs similarity index 61% rename from src/Mistruna.Core/Abstractions/RequestKind.cs rename to src/Mistruna.Core.Abstractions/Cqrs/RequestKind.cs index 5636325..d324df4 100644 --- a/src/Mistruna.Core/Abstractions/RequestKind.cs +++ b/src/Mistruna.Core.Abstractions/Cqrs/RequestKind.cs @@ -1,30 +1,20 @@ -namespace Mistruna.Core.Abstractions; +namespace Mistruna.Core.Abstractions.Cqrs; -/// -/// Helpers for distinguishing CQRS request types in MediatR pipeline behaviors. -/// +/// Classifies CQRS request types. public static class RequestKind { - /// - /// Returns when implements - /// or . - /// + /// Returns whether a type implements a command marker. public static bool IsCommand(Type requestType) { ArgumentNullException.ThrowIfNull(requestType); - return typeof(ICommand).IsAssignableFrom(requestType) || requestType.GetInterfaces().Any(IsCommandInterface); } - /// - /// Returns when implements - /// . - /// + /// Returns whether a type implements a query marker. public static bool IsQuery(Type requestType) { ArgumentNullException.ThrowIfNull(requestType); - return requestType.GetInterfaces().Any(IsQueryInterface); } diff --git a/src/Mistruna.Core.Abstractions/Entities/CommonValueObjects.cs b/src/Mistruna.Core.Abstractions/Entities/CommonValueObjects.cs new file mode 100644 index 0000000..e481a1c --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Entities/CommonValueObjects.cs @@ -0,0 +1,261 @@ +using System.Text.RegularExpressions; + +namespace Mistruna.Core.Abstractions.Entities; + +/// Represents an email address. +public sealed class Email : ValueObject +{ + private static readonly Regex Pattern = new( + @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private Email(string value) => Value = value.ToLowerInvariant(); + + /// Gets the normalized email address. + public string Value { get; } + + /// Creates an email address. + public static Email Create(string email) + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be null or empty.", nameof(email)); + if (!Pattern.IsMatch(email)) + throw new ArgumentException("Invalid email format.", nameof(email)); + return new Email(email); + } + + /// Attempts to create an email address. + public static bool TryCreate(string email, out Email? result) + { + result = null; + if (string.IsNullOrWhiteSpace(email) || !Pattern.IsMatch(email)) + return false; + result = new Email(email); + return true; + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + /// + public override string ToString() => Value; + + /// Converts an email to text. + public static implicit operator string(Email email) => email.Value; +} + +/// Represents a normalized phone number. +public sealed class PhoneNumber : ValueObject +{ + private static readonly Regex Pattern = new(@"^\+?[1-9]\d{1,14}$", RegexOptions.Compiled); + private PhoneNumber(string value) => Value = value; + + /// Gets the normalized phone number. + public string Value { get; } + + /// Creates a phone number. + public static PhoneNumber Create(string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + throw new ArgumentException("Phone number cannot be null or empty.", nameof(phoneNumber)); + var normalized = Normalize(phoneNumber); + if (!Pattern.IsMatch(normalized)) + throw new ArgumentException("Invalid phone number format.", nameof(phoneNumber)); + return new PhoneNumber(normalized); + } + + /// Attempts to create a phone number. + public static bool TryCreate(string phoneNumber, out PhoneNumber? result) + { + result = null; + if (string.IsNullOrWhiteSpace(phoneNumber)) + return false; + var normalized = Normalize(phoneNumber); + if (!Pattern.IsMatch(normalized)) + return false; + result = new PhoneNumber(normalized); + return true; + } + + private static string Normalize(string value) => Regex.Replace(value, @"[\s\-\(\)\.]", ""); + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + /// + public override string ToString() => Value; + + /// Converts a phone number to text. + public static implicit operator string(PhoneNumber phone) => phone.Value; +} + +/// Represents a monetary amount and currency. +public sealed class Money : ValueObject, IComparable +{ + private Money(decimal amount, string currency) + { + Amount = amount; + Currency = currency.ToUpperInvariant(); + } + + /// Gets the amount. + public decimal Amount { get; } + /// Gets the ISO currency code. + public string Currency { get; } + + /// Creates money rounded to two decimal places. + public static Money Create(decimal amount, string currency) + { + if (string.IsNullOrWhiteSpace(currency)) + throw new ArgumentException("Currency cannot be null or empty.", nameof(currency)); + if (currency.Length != 3) + throw new ArgumentException("Currency must be a 3-letter code.", nameof(currency)); + return new Money(Math.Round(amount, 2), currency); + } + + /// Creates zero money in a currency. + public static Money Zero(string currency) => Create(0, currency); + /// Adds money with the same currency. + public static Money operator +(Money left, Money right) => Create(CompareCurrency(left, right, left.Amount + right.Amount), left.Currency); + /// Subtracts money with the same currency. + public static Money operator -(Money left, Money right) => Create(CompareCurrency(left, right, left.Amount - right.Amount), left.Currency); + /// Multiplies money by a scalar. + public static Money operator *(Money money, decimal multiplier) => Create(money.Amount * multiplier, money.Currency); + /// Divides money by a scalar. + public static Money operator /(Money money, decimal divisor) => + divisor == 0 ? throw new DivideByZeroException() : Create(money.Amount / divisor, money.Currency); + /// Compares monetary amounts. + public static bool operator <(Money left, Money right) => CompareCurrency(left, right, left.Amount) < right.Amount; + /// Compares monetary amounts. + public static bool operator >(Money left, Money right) => CompareCurrency(left, right, left.Amount) > right.Amount; + /// Compares monetary amounts. + public static bool operator <=(Money left, Money right) => CompareCurrency(left, right, left.Amount) <= right.Amount; + /// Compares monetary amounts. + public static bool operator >=(Money left, Money right) => CompareCurrency(left, right, left.Amount) >= right.Amount; + + /// + public int CompareTo(Money? other) + { + if (other is null) + return 1; + EnsureSameCurrency(this, other); + return Amount.CompareTo(other.Amount); + } + + private static decimal CompareCurrency(Money left, Money right, decimal value) + { + EnsureSameCurrency(left, right); + return value; + } + + private static void EnsureSameCurrency(Money left, Money right) + { + if (left.Currency != right.Currency) + throw new InvalidOperationException( + $"Cannot perform operation on different currencies: {left.Currency} and {right.Currency}"); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Amount; + yield return Currency; + } + + /// + public override string ToString() => $"{Amount:N2} {Currency}"; +} + +/// Represents a postal address. +public sealed class Address : ValueObject +{ + private Address(string street, string city, string? state, string postalCode, string country) + { + Street = street; + City = city; + State = state; + PostalCode = postalCode; + Country = country; + } + + /// Gets the street. + public string Street { get; } + /// Gets the city. + public string City { get; } + /// Gets the state or province. + public string? State { get; } + /// Gets the postal code. + public string PostalCode { get; } + /// Gets the country. + public string Country { get; } + + /// Creates an address. + public static Address Create(string street, string city, string postalCode, string country, string? state = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(street); + ArgumentException.ThrowIfNullOrWhiteSpace(city); + ArgumentException.ThrowIfNullOrWhiteSpace(postalCode); + ArgumentException.ThrowIfNullOrWhiteSpace(country); + return new Address(street.Trim(), city.Trim(), state?.Trim(), postalCode.Trim(), country.Trim()); + } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Street; + yield return City; + yield return State; + yield return PostalCode; + yield return Country; + } + + /// + public override string ToString() => + string.Join(", ", new[] { Street, City, State, PostalCode, Country }.Where(value => !string.IsNullOrWhiteSpace(value))); +} + +/// Represents an inclusive date range. +public sealed class DateRange : ValueObject +{ + private DateRange(DateTime start, DateTime end) + { + Start = start; + End = end; + } + + /// Gets the start. + public DateTime Start { get; } + /// Gets the end. + public DateTime End { get; } + /// Gets the duration. + public TimeSpan Duration => End - Start; + + /// Creates a date range. + public static DateRange Create(DateTime start, DateTime end) + { + if (end < start) + throw new ArgumentException("End date must be greater than or equal to start date.", nameof(end)); + return new DateRange(start, end); + } + + /// Returns whether a date is in the range. + public bool Contains(DateTime date) => date >= Start && date <= End; + /// Returns whether another range overlaps this range. + public bool Overlaps(DateRange other) => Start <= other.End && End >= other.Start; + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Start; + yield return End; + } + + /// + public override string ToString() => $"{Start:d} - {End:d}"; +} diff --git a/src/Mistruna.Core.Abstractions/Entities/Entity.cs b/src/Mistruna.Core.Abstractions/Entities/Entity.cs new file mode 100644 index 0000000..5abb718 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Entities/Entity.cs @@ -0,0 +1,135 @@ +namespace Mistruna.Core.Abstractions.Entities; + +/// Represents an entity with an identifier. +public interface IEntity where TId : IEquatable +{ + /// Gets the identifier. + TId Id { get; } +} + +/// Represents audit metadata. +public interface IAuditableEntity +{ + /// Gets or sets the creation time. + DateTime CreatedAt { get; set; } + /// Gets or sets the creator. + string? CreatedBy { get; set; } + /// Gets or sets the modification time. + DateTime? ModifiedAt { get; set; } + /// Gets or sets the modifier. + string? ModifiedBy { get; set; } +} + +/// Represents soft-deletion metadata. +public interface ISoftDeletable +{ + /// Gets or sets whether the entity is deleted. + bool IsDeleted { get; set; } + /// Gets or sets the deletion time. + DateTime? DeletedAt { get; set; } + /// Gets or sets who deleted the entity. + string? DeletedBy { get; set; } +} + +/// Base entity identified by . +public abstract class Entity : IEntity, IEquatable> + where TId : IEquatable +{ + /// + public virtual TId Id { get; protected set; } = default!; + + /// + public bool Equals(Entity? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + if (Id.Equals(default!) || other.Id.Equals(default!)) + return false; + return Id.Equals(other.Id); + } + + /// + public override bool Equals(object? obj) => obj is Entity entity && Equals(entity); + + /// + public override int GetHashCode() => (GetType().GetHashCode() * 907) + Id.GetHashCode(); + + /// Compares entities for equality. + public static bool operator ==(Entity? left, Entity? right) => + left is null ? right is null : left.Equals(right); + + /// Compares entities for inequality. + public static bool operator !=(Entity? left, Entity? right) => !(left == right); +} + +/// Base entity identified by a GUID. +public abstract class Entity : Entity +{ + /// Initializes an entity with a new GUID. + protected Entity() => Id = Guid.NewGuid(); + /// Initializes an entity with a GUID. + protected Entity(Guid id) => Id = id; +} + +/// Base auditable entity. +public abstract class AuditableEntity : Entity, IAuditableEntity where TId : IEquatable +{ + /// + public DateTime CreatedAt { get; set; } + /// + public string? CreatedBy { get; set; } + /// + public DateTime? ModifiedAt { get; set; } + /// + public string? ModifiedBy { get; set; } +} + +/// Base auditable GUID entity. +public abstract class AuditableEntity : AuditableEntity +{ + /// Initializes an entity with a new GUID. + protected AuditableEntity() => Id = Guid.NewGuid(); + /// Initializes an entity with a GUID. + protected AuditableEntity(Guid id) => Id = id; +} + +/// Base soft-deletable entity. +public abstract class SoftDeletableEntity : AuditableEntity, ISoftDeletable + where TId : IEquatable +{ + /// + public bool IsDeleted { get; set; } + /// + public DateTime? DeletedAt { get; set; } + /// + public string? DeletedBy { get; set; } + + /// Marks the entity as deleted. + public virtual void Delete(string? deletedBy = null) + { + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + DeletedBy = deletedBy; + } + + /// Restores the entity. + public virtual void Restore() + { + IsDeleted = false; + DeletedAt = null; + DeletedBy = null; + } +} + +/// Base soft-deletable GUID entity. +public abstract class SoftDeletableEntity : SoftDeletableEntity +{ + /// Initializes an entity with a new GUID. + protected SoftDeletableEntity() => Id = Guid.NewGuid(); + /// Initializes an entity with a GUID. + protected SoftDeletableEntity(Guid id) => Id = id; +} diff --git a/src/Mistruna.Core.Abstractions/Entities/ValueObject.cs b/src/Mistruna.Core.Abstractions/Entities/ValueObject.cs new file mode 100644 index 0000000..83c6d2b --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Entities/ValueObject.cs @@ -0,0 +1,54 @@ +namespace Mistruna.Core.Abstractions.Entities; + +/// Base class for immutable, value-compared objects. +public abstract class ValueObject : IEquatable +{ + /// Returns the atomic equality components. + protected abstract IEnumerable GetEqualityComponents(); + + /// + public bool Equals(ValueObject? other) => Equals((object?)other); + + /// + public override bool Equals(object? obj) => + obj is not null && + obj.GetType() == GetType() && + GetEqualityComponents().SequenceEqual(((ValueObject)obj).GetEqualityComponents()); + + /// + public override int GetHashCode() => + GetEqualityComponents().Select(component => component?.GetHashCode() ?? 0).Aggregate((x, y) => x ^ y); + + /// Compares value objects for equality. + public static bool operator ==(ValueObject? left, ValueObject? right) => + left is null ? right is null : left.Equals(right); + + /// Compares value objects for inequality. + public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right); +} + +/// Base class for a value object containing one comparable value. +public abstract class SingleValueObject : ValueObject, IComparable> + where T : IComparable +{ + /// Initializes the value object. + protected SingleValueObject(T value) => Value = value; + + /// Gets the wrapped value. + public T Value { get; } + + /// + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + /// + public int CompareTo(SingleValueObject? other) => other is null ? 1 : Value.CompareTo(other.Value); + + /// + public override string ToString() => Value?.ToString() ?? string.Empty; + + /// Converts to the wrapped value. + public static implicit operator T(SingleValueObject valueObject) => valueObject.Value; +} diff --git a/src/Mistruna.Core.Abstractions/Errors/IValidationErrorProvider.cs b/src/Mistruna.Core.Abstractions/Errors/IValidationErrorProvider.cs new file mode 100644 index 0000000..74bd328 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Errors/IValidationErrorProvider.cs @@ -0,0 +1,12 @@ +using FluentValidation.Results; + +namespace Mistruna.Core.Abstractions.Errors; + +/// Creates common validation failures. +public interface IValidationErrorProvider +{ + /// Returns a not-found validation failure. + IEnumerable NotFound(); + /// Returns a not-found validation failure for an identifier. + IEnumerable NotFound(Guid id); +} diff --git a/src/Mistruna.Core.Abstractions/Mistruna.Core.Abstractions.csproj b/src/Mistruna.Core.Abstractions/Mistruna.Core.Abstractions.csproj new file mode 100644 index 0000000..545d342 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Mistruna.Core.Abstractions.csproj @@ -0,0 +1,18 @@ + + + net8.0;net10.0 + true + Mistruna.Core.Abstractions + Shared abstractions for Mistruna.Core: Result, CQRS markers, entities, persistence contracts. + Mistruna.Core.Abstractions + + + + + + + + + + + diff --git a/src/Mistruna.Core.Abstractions/Persistence/IAuditable.cs b/src/Mistruna.Core.Abstractions/Persistence/IAuditable.cs new file mode 100644 index 0000000..ade1f07 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/IAuditable.cs @@ -0,0 +1,10 @@ +namespace Mistruna.Core.Abstractions.Persistence; + +/// Represents an entity with user audit metadata. +public interface IAuditable : IEntity +{ + /// Gets or sets the creator identifier. + Guid CreatedBy { get; set; } + /// Gets or sets the modifier identifier. + Guid? ModifiedBy { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Persistence/ICacheService.cs b/src/Mistruna.Core.Abstractions/Persistence/ICacheService.cs new file mode 100644 index 0000000..e4d572e --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/ICacheService.cs @@ -0,0 +1,35 @@ +namespace Mistruna.Core.Abstractions.Persistence; + +/// Provides asynchronous cache operations. +public interface ICacheService +{ + /// Gets a cached value. + Task GetAsync(string key, CancellationToken cancellationToken = default); + /// Sets a cached value. + Task SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default); + /// Gets or creates a cached value. + Task GetOrCreateAsync( + string key, + Func> factory, + TimeSpan? expiration = null, + CancellationToken cancellationToken = default); + /// Removes a cached value. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + /// Removes cached values matching a pattern. + Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default); + /// Checks whether a cache key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); + /// Refreshes a cached value's expiration. + Task RefreshAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default); +} + +/// Configures cache behavior. +public class CacheOptions +{ + /// Gets or sets the default expiration. + public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromMinutes(5); + /// Gets or sets the cache key prefix. + public string KeyPrefix { get; set; } = string.Empty; + /// Gets or sets whether sliding expiration is used. + public bool UseSlidingExpiration { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Persistence/IDomainEvent.cs b/src/Mistruna.Core.Abstractions/Persistence/IDomainEvent.cs new file mode 100644 index 0000000..4344b2a --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/IDomainEvent.cs @@ -0,0 +1,46 @@ +namespace Mistruna.Core.Abstractions.Persistence; + +/// Marks a domain event. +public interface IDomainEvent +{ + /// Gets when the event occurred. + DateTime OccurredAt { get; } +} + +/// Handles a domain event. +public interface IDomainEventHandler where TEvent : IDomainEvent +{ + /// Handles the event. + Task HandleAsync(TEvent domainEvent, CancellationToken cancellationToken = default); +} + +/// Dispatches domain events. +public interface IDomainEventDispatcher +{ + /// Dispatches an event to registered handlers. + Task DispatchAsync(TEvent domainEvent, CancellationToken cancellationToken = default) + where TEvent : IDomainEvent; +} + +/// Base persistence entity that raises domain events. +public abstract class AggregateRoot : IEntity +{ + private readonly List _domainEvents = new(); + + /// + public Guid Id { get; set; } + /// + public DateTimeOffset CreatedAt { get; set; } + /// + public DateTimeOffset? UpdatedAt { get; set; } + + /// Gets pending domain events. + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// Adds a domain event. + protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); + /// Removes a domain event. + protected void RemoveDomainEvent(IDomainEvent domainEvent) => _domainEvents.Remove(domainEvent); + /// Clears pending domain events. + public void ClearDomainEvents() => _domainEvents.Clear(); +} diff --git a/src/Mistruna.Core.Abstractions/Persistence/IEntity.cs b/src/Mistruna.Core.Abstractions/Persistence/IEntity.cs new file mode 100644 index 0000000..26e4d6d --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/IEntity.cs @@ -0,0 +1,12 @@ +namespace Mistruna.Core.Abstractions.Persistence; + +/// Represents an entity with audit timestamps. +public interface IEntity +{ + /// Gets or sets the identifier. + Guid Id { get; set; } + /// Gets or sets the creation time. + DateTimeOffset CreatedAt { get; set; } + /// Gets or sets the last update time. + DateTimeOffset? UpdatedAt { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Persistence/IGenericRepository.cs b/src/Mistruna.Core.Abstractions/Persistence/IGenericRepository.cs new file mode 100644 index 0000000..daa8941 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/IGenericRepository.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; + +namespace Mistruna.Core.Abstractions.Persistence; + +/// Read-only repository contract. +public interface IReadRepository where T : class, IEntity +{ + /// Gets an entity by identifier. + ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// Gets all entities. + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); + /// Finds entities using a predicate. + Task> FindAsync(Expression> predicate, CancellationToken cancellationToken = default); + /// Finds entities using a specification. + Task> FindAsync(ISpecification specification, CancellationToken cancellationToken = default); + /// Finds one entity using a specification. + Task FindOneAsync(ISpecification specification, CancellationToken cancellationToken = default); + /// Counts entities using a specification. + Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default); + /// Counts entities using an optional predicate. + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + /// Checks whether any entity matches. + Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default); + /// Returns an advanced query surface. + IQueryable AsQueryable(); +} + +/// Write-only repository contract. +public interface IWriteRepository where T : class, IEntity +{ + /// Adds an entity to the current persistence context. + ValueTask AddAsync(T entity, CancellationToken cancellationToken = default); + /// Adds multiple entities. + Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + /// Updates an entity. + Task UpdateAsync(T entity, CancellationToken cancellationToken = default); + /// Updates multiple entities. + Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + /// Deletes an entity. + Task DeleteAsync(T entity, CancellationToken cancellationToken = default); + /// Deletes multiple entities. + Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); +} + +/// Combined read/write repository contract. +public interface IGenericRepository : IReadRepository, IWriteRepository where T : class, IEntity; diff --git a/src/Mistruna.Core.Abstractions/Persistence/ISpecification.cs b/src/Mistruna.Core.Abstractions/Persistence/ISpecification.cs new file mode 100644 index 0000000..c41c7a0 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/ISpecification.cs @@ -0,0 +1,80 @@ +using System.Linq.Expressions; + +namespace Mistruna.Core.Abstractions.Persistence; + +/// Encapsulates reusable query criteria. +public interface ISpecification where T : class +{ + /// Gets the filter criteria. + Expression>? Criteria { get; } + /// Gets expression-based includes. + List>> Includes { get; } + /// Gets string-based includes. + List IncludeStrings { get; } + /// Gets ascending ordering. + Expression>? OrderBy { get; } + /// Gets descending ordering. + Expression>? OrderByDescending { get; } + /// Gets the take count. + int? Take { get; } + /// Gets the skip count. + int? Skip { get; } + /// Gets whether paging is enabled. + bool IsPagingEnabled { get; } + /// Gets whether split queries are enabled. + bool IsSplitQuery { get; } + /// Gets whether change tracking is disabled. + bool IsNoTracking { get; } +} + +/// Base implementation of a query specification. +public abstract class Specification : ISpecification where T : class +{ + /// Initializes an empty specification. + protected Specification() { } + /// Initializes a specification with criteria. + protected Specification(Expression> criteria) => Criteria = criteria; + + /// + public Expression>? Criteria { get; private set; } + /// + public List>> Includes { get; } = new(); + /// + public List IncludeStrings { get; } = new(); + /// + public Expression>? OrderBy { get; private set; } + /// + public Expression>? OrderByDescending { get; private set; } + /// + public int? Take { get; private set; } + /// + public int? Skip { get; private set; } + /// + public bool IsPagingEnabled { get; private set; } + /// + public bool IsSplitQuery { get; private set; } + /// + public bool IsNoTracking { get; private set; } = true; + + /// Sets filter criteria. + protected void AddCriteria(Expression> criteria) => Criteria = criteria; + /// Adds an expression include. + protected void AddInclude(Expression> includeExpression) => Includes.Add(includeExpression); + /// Adds a string include. + protected void AddInclude(string includeString) => IncludeStrings.Add(includeString); + /// Sets ascending ordering. + protected void ApplyOrderBy(Expression> expression) => OrderBy = expression; + /// Sets descending ordering. + protected void ApplyOrderByDescending(Expression> expression) => OrderByDescending = expression; + /// Applies paging. + protected void ApplyPaging(int skip, int take) + { + Skip = skip; + Take = take; + IsPagingEnabled = true; + } + /// Enables split queries. + protected void EnableSplitQuery() => IsSplitQuery = true; + /// Enables change tracking. + protected void EnableTracking() => IsNoTracking = false; +} diff --git a/src/Mistruna.Core.Abstractions/Persistence/IUnitOfWork.cs b/src/Mistruna.Core.Abstractions/Persistence/IUnitOfWork.cs new file mode 100644 index 0000000..843b738 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Persistence/IUnitOfWork.cs @@ -0,0 +1,32 @@ +namespace Mistruna.Core.Abstractions.Persistence; + +/// Coordinates persistence operations and transactions. +public interface IUnitOfWork : IDisposable, IAsyncDisposable +{ + /// Saves pending changes. + Task SaveChangesAsync(CancellationToken cancellationToken = default); + /// Begins a transaction. + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + /// Executes an action in a transaction. + Task ExecuteInTransactionAsync( + Func action, + CancellationToken cancellationToken = default); + /// Executes a result-producing action in a transaction. + Task ExecuteInTransactionAsync( + Func> action, + CancellationToken cancellationToken = default); +} + +/// Represents a transaction owned by a unit of work. +public interface IUnitOfWorkTransaction : IDisposable, IAsyncDisposable +{ + /// Gets the transaction identifier. + Guid TransactionId { get; } + /// Commits the transaction. + Task CommitAsync(CancellationToken cancellationToken = default); + /// Rolls back the transaction. + Task RollbackAsync(CancellationToken cancellationToken = default); +} + +/// Compatibility alias for a unit-of-work transaction. +public interface IDbTransaction : IUnitOfWorkTransaction; diff --git a/src/Mistruna.Core.Abstractions/Responses/DeleteResponse.cs b/src/Mistruna.Core.Abstractions/Responses/DeleteResponse.cs new file mode 100644 index 0000000..008bfdf --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/DeleteResponse.cs @@ -0,0 +1,10 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents a deletion response. +public class DeleteResponse : IResponse +{ + /// + public string? Message { get; set; } + /// Gets or sets the deleted identifier. + public Guid Id { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Responses/ExistResponse.cs b/src/Mistruna.Core.Abstractions/Responses/ExistResponse.cs new file mode 100644 index 0000000..a731b06 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/ExistResponse.cs @@ -0,0 +1,10 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents an existence-check response. +public class ExistResponse : IResponse +{ + /// + public string? Message { get; set; } + /// Gets or sets whether the item exists. + public bool Exist { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Responses/IResponse.cs b/src/Mistruna.Core.Abstractions/Responses/IResponse.cs new file mode 100644 index 0000000..29d95db --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/IResponse.cs @@ -0,0 +1,8 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents a response with an optional message. +public interface IResponse +{ + /// Gets or sets the response message. + string? Message { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Responses/ItemResponse.cs b/src/Mistruna.Core.Abstractions/Responses/ItemResponse.cs new file mode 100644 index 0000000..a220378 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/ItemResponse.cs @@ -0,0 +1,10 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents a response containing one item. +public class ItemResponse : IResponse where T : class +{ + /// + public string? Message { get; set; } + /// Gets or sets the returned item. + public T? Item { get; set; } +} diff --git a/src/Mistruna.Core.Abstractions/Responses/PageView.cs b/src/Mistruna.Core.Abstractions/Responses/PageView.cs new file mode 100644 index 0000000..14d2285 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/PageView.cs @@ -0,0 +1,32 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents a page of items and navigation metadata. +public class PageView where TModel : class +{ + /// Gets or sets the one-based page number. + public int Page { get; set; } + /// Gets or sets the page size. + public int Count { get; set; } + /// Gets or sets the total item count. + public int Total { get; set; } + /// Gets or sets the page items. + public IList Elements { get; set; } = new List(); + /// Gets the total page count. + public int TotalPages => Count > 0 ? (int)Math.Ceiling((double)Total / Count) : 0; + /// Gets whether a previous page exists. + public bool HasPreviousPage => Page > 1; + /// Gets whether a next page exists. + public bool HasNextPage => Page < TotalPages; + /// Gets the one-based first item number. + public int FirstItemOnPage => Total > 0 ? (Page - 1) * Count + 1 : 0; + /// Gets the one-based last item number. + public int LastItemOnPage => Total > 0 ? Math.Min(Page * Count, Total) : 0; + + /// Creates an empty page. + public static PageView Empty(int page = 1, int count = 10) => + new() { Page = page, Count = count }; + + /// Creates a populated page. + public static PageView Create(IList items, int totalCount, int page, int pageSize) => + new() { Page = page, Count = pageSize, Total = totalCount, Elements = items }; +} diff --git a/src/Mistruna.Core.Abstractions/Responses/PageViewResponse.cs b/src/Mistruna.Core.Abstractions/Responses/PageViewResponse.cs new file mode 100644 index 0000000..b6a2805 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Responses/PageViewResponse.cs @@ -0,0 +1,8 @@ +namespace Mistruna.Core.Abstractions.Responses; + +/// Represents a paginated response. +public class PageViewResponse : PageView, IResponse where T : class +{ + /// + public string? Message { get; set; } +} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/PageViewResponseExtensions.cs b/src/Mistruna.Core.Abstractions/Responses/PageViewResponseExtensions.cs similarity index 60% rename from src/Mistruna.Core.Contracts/Base/Responses/PageViewResponseExtensions.cs rename to src/Mistruna.Core.Abstractions/Responses/PageViewResponseExtensions.cs index 3a07002..2481488 100644 --- a/src/Mistruna.Core.Contracts/Base/Responses/PageViewResponseExtensions.cs +++ b/src/Mistruna.Core.Abstractions/Responses/PageViewResponseExtensions.cs @@ -1,13 +1,9 @@ -namespace Mistruna.Core.Contracts.Base.Responses; +namespace Mistruna.Core.Abstractions.Responses; -/// -/// Convenience methods for populating paginated responses. -/// +/// Provides page response construction helpers. public static class PageViewResponseExtensions { - /// - /// Populates a paginated response with message, metadata and elements. - /// + /// Populates a paginated response. public static TResponse WithPage( this TResponse response, string? message, @@ -23,19 +19,16 @@ public static TResponse WithPage( response.Count = count; response.Total = total; response.Elements = elements; - return response; } - /// - /// Populates a paginated response using the element count as the page size and total. - /// + /// Populates a page using the element count as its size and total. public static TResponse WithPage( this TResponse response, string? message, IList elements, int page = 1) where TResponse : PageViewResponse - where TModel : class - => response.WithPage(message, elements, elements.Count, page, elements.Count); + where TModel : class => + response.WithPage(message, elements, elements.Count, page, elements.Count); } diff --git a/src/Mistruna.Core.Abstractions/Results/Error.cs b/src/Mistruna.Core.Abstractions/Results/Error.cs new file mode 100644 index 0000000..55e8890 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Results/Error.cs @@ -0,0 +1,53 @@ +namespace Mistruna.Core.Abstractions.Results; + +/// Represents an error with a code, description, and category. +public sealed record Error +{ + /// Represents no error. + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None); + + /// Represents an unexpected null value. + public static readonly Error NullValue = + new("Error.NullValue", "A null value was provided", ErrorType.Validation); + + /// Initializes an error. + public Error(string code, string description, ErrorType type = ErrorType.Failure) + { + Code = code; + Description = description; + Type = type; + } + + /// Gets the unique error code. + public string Code { get; } + + /// Gets the human-readable description. + public string Description { get; } + + /// Gets the error category. + public ErrorType Type { get; } + + /// Creates a general failure. + public static Error Failure(string code, string description) => new(code, description, ErrorType.Failure); + + /// Creates a validation error. + public static Error Validation(string code, string description) => new(code, description, ErrorType.Validation); + + /// Creates a not-found error. + public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); + + /// Creates a conflict error. + public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); + + /// Creates an unauthorized error. + public static Error Unauthorized(string code, string description) => new(code, description, ErrorType.Unauthorized); + + /// Creates a forbidden error. + public static Error Forbidden(string code, string description) => new(code, description, ErrorType.Forbidden); + + /// Returns the error code. + public static implicit operator string(Error error) => error.Code; + + /// + public override string ToString() => Code; +} diff --git a/src/Mistruna.Core.Abstractions/Results/ErrorType.cs b/src/Mistruna.Core.Abstractions/Results/ErrorType.cs new file mode 100644 index 0000000..f848b97 --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Results/ErrorType.cs @@ -0,0 +1,20 @@ +namespace Mistruna.Core.Abstractions.Results; + +/// Identifies the category of an error. +public enum ErrorType +{ + /// No error. + None = 0, + /// General failure. + Failure = 1, + /// Validation error. + Validation = 2, + /// Resource not found. + NotFound = 3, + /// Resource conflict. + Conflict = 4, + /// Unauthorized access. + Unauthorized = 5, + /// Forbidden access. + Forbidden = 6 +} diff --git a/src/Mistruna.Core.Abstractions/Results/Result.cs b/src/Mistruna.Core.Abstractions/Results/Result.cs new file mode 100644 index 0000000..0a11fca --- /dev/null +++ b/src/Mistruna.Core.Abstractions/Results/Result.cs @@ -0,0 +1,164 @@ +namespace Mistruna.Core.Abstractions.Results; + +/// Represents the success or failure of an operation. +public class Result +{ + /// Initializes a result with one error. + protected Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None) + throw new InvalidOperationException("Success result cannot have an error."); + if (!isSuccess && error == Error.None) + throw new InvalidOperationException("Failure result must have an error."); + + IsSuccess = isSuccess; + Error = error; + } + + /// Initializes a result with multiple errors. + protected Result(bool isSuccess, Error[] errors) + { + IsSuccess = isSuccess; + Errors = errors; + Error = errors.FirstOrDefault() ?? Error.None; + } + + /// Gets whether the operation succeeded. + public bool IsSuccess { get; } + + /// Gets whether the operation failed. + public bool IsFailure => !IsSuccess; + + /// Gets the primary error. + public Error Error { get; } + + /// Gets all errors. + public Error[] Errors { get; } = Array.Empty(); + + /// Creates a successful result. + public static Result Success() => new(true, Error.None); + + /// Creates a successful result containing a value. + public static Result Success(TValue value) => new(value, true, Error.None); + + /// Creates a failed result. + public static Result Failure(Error error) => new(false, error); + + /// Creates a failed result of the requested value type. + public static Result Failure(Error error) => new(default, false, error); + + /// Creates a failed result with multiple errors. + public static Result Failure(Error[] errors) => new(default, false, errors); + + /// Creates a result from a condition. + public static Result Create(bool condition, Error error) => condition ? Success() : Failure(error); + + /// Creates a result from a nullable value. + public static Result Create(TValue? value) => + value is not null ? Success(value) : Failure(Error.NullValue); + + /// Combines results, retaining all failures. + public static Result Combine(params Result[] results) + { + var failures = results.Where(result => result.IsFailure).ToArray(); + return failures.Length == 0 + ? Success() + : new Result(false, failures.Select(result => result.Error).ToArray()); + } +} + +/// Represents an operation result containing a value on success. +/// The value type. +public class Result : Result +{ + private readonly TValue? _value; + + internal Result(TValue? value, bool isSuccess, Error error) : base(isSuccess, error) => _value = value; + + internal Result(TValue? value, bool isSuccess, Error[] errors) : base(isSuccess, errors) => _value = value; + + /// Gets the value, or throws when the result failed. + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Cannot access value of a failed result."); + + /// Gets the value or its default. + public TValue? ValueOrDefault => _value; + + /// Converts a non-null value to success and null to failure. + public static implicit operator Result(TValue? value) => + value is not null ? Success(value) : Failure(Error.NullValue); + + /// Converts an error to failure. + public static implicit operator Result(Error error) => Failure(error); + + /// Maps a successful value. + public Result Map(Func mapper) => + IsSuccess ? Success(mapper(_value!)) : Failure(Error); + + /// Binds a successful value to another result. + public Result Bind(Func> binder) => + IsSuccess ? binder(_value!) : Failure(Error); + + /// Matches the success or failure branch. + public TResult Match(Func onSuccess, Func onFailure) => + IsSuccess ? onSuccess(_value!) : onFailure(Error); + + /// Executes an action when successful. + public Result Tap(Action action) + { + if (IsSuccess) + action(_value!); + return this; + } + + /// Returns the value or the supplied default. + public TValue GetValueOrDefault(TValue defaultValue) => IsSuccess ? _value! : defaultValue; + + /// Returns the value or invokes the supplied factory. + public TValue GetValueOrDefault(Func factory) => IsSuccess ? _value! : factory(); +} + +/// Provides composition helpers for results. +public static class ResultExtensions +{ + /// Converts a nullable reference to a result. + public static Result ToResult(this T? value, Error error) where T : class => + value is not null ? Result.Success(value) : Result.Failure(error); + + /// Converts a nullable value type to a result. + public static Result ToResult(this T? value, Error error) where T : struct => + value.HasValue ? Result.Success(value.Value) : Result.Failure(error); + + /// Ensures a predicate holds for a successful result. + public static Result Ensure(this Result result, Func predicate, Error error) => + result.IsFailure ? result : predicate(result.Value) ? result : Result.Failure(error); + + /// Combines two successful results into a tuple. + public static Result<(T1, T2)> Combine(this Result first, Result second) + { + if (first.IsFailure) + return Result.Failure<(T1, T2)>(first.Error); + return second.IsFailure + ? Result.Failure<(T1, T2)>(second.Error) + : Result.Success((first.Value, second.Value)); + } + + /// Combines a sequence of results. + public static Result> Combine(this IEnumerable> results) + { + var values = new List(); + var errors = new List(); + foreach (var result in results) + { + if (result.IsSuccess) + values.Add(result.Value); + else + errors.Add(result.Error); + } + + return errors.Count == 0 + ? Result.Success>(values) + : new Result>(default, false, errors.ToArray()); + } +} diff --git a/src/Mistruna.Core.AspNetCore/Authentication/JwtServiceCollectionExtensions.cs b/src/Mistruna.Core.AspNetCore/Authentication/JwtServiceCollectionExtensions.cs new file mode 100644 index 0000000..c9b554a --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Authentication/JwtServiceCollectionExtensions.cs @@ -0,0 +1,65 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Mistruna.Core.AspNetCore.Authentication; + +public static class JwtServiceCollectionExtensions +{ + public static IServiceCollection AddMistrunaJwtAuthorization( + this IServiceCollection services, + IConfiguration configuration, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var options = new MistrunaJwtAuthorizationOptions + { + Issuer = configuration["Jwt:Issuer"] ?? "Mistruna", + Audience = configuration["Jwt:Audience"] ?? configuration["Jwt:Issuer"] ?? "Mistruna", + Key = configuration["Jwt:Key"] ?? Environment.GetEnvironmentVariable("KEY"), + KeyId = configuration["Jwt:KeyId"] ?? "mistruna-signing-key" + }; + + configure?.Invoke(options); + + if (string.IsNullOrWhiteSpace(options.Key)) + { + throw new InvalidOperationException( + "JWT signing key is not configured. Set 'Jwt:Key' or the 'KEY' environment variable."); + } + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Key)) + { + KeyId = options.KeyId + }; + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwt => + { + jwt.RequireHttpsMetadata = options.RequireHttpsMetadata; + jwt.SaveToken = true; + jwt.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = options.Issuer, + ValidAudience = options.Audience, + IssuerSigningKey = signingKey, + ClockSkew = TimeSpan.Zero, + RoleClaimType = options.RoleClaimType, + TryAllIssuerSigningKeys = true + }; + }); + + services.AddAuthorization(authorization => + authorization.AddPolicy( + MistrunaAuthorizationPolicies.AdminOnly, + policy => policy.RequireRole(MistrunaRoles.Administrator))); + return services; + } +} diff --git a/src/Mistruna.Core.AspNetCore/Authentication/MistrunaJwtAuthorizationOptions.cs b/src/Mistruna.Core.AspNetCore/Authentication/MistrunaJwtAuthorizationOptions.cs new file mode 100644 index 0000000..92d2c2e --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Authentication/MistrunaJwtAuthorizationOptions.cs @@ -0,0 +1,26 @@ +namespace Mistruna.Core.AspNetCore.Authentication; + +public static class MistrunaAuthorizationPolicies +{ + public const string AdminOnly = nameof(AdminOnly); +} + +public static class MistrunaRoles +{ + public const string Administrator = nameof(Administrator); +} + +public sealed class MistrunaJwtAuthorizationOptions +{ + public string Issuer { get; set; } = "Mistruna"; + + public string Audience { get; set; } = "Mistruna"; + + public string? Key { get; set; } + + public string? KeyId { get; set; } = "mistruna-signing-key"; + + public string RoleClaimType { get; set; } = "role"; + + public bool RequireHttpsMetadata { get; set; } = true; +} diff --git a/src/Mistruna.Core.AspNetCore/Cors/CorsServiceCollectionExtensions.cs b/src/Mistruna.Core.AspNetCore/Cors/CorsServiceCollectionExtensions.cs new file mode 100644 index 0000000..2849896 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Cors/CorsServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Mistruna.Core.AspNetCore.Cors; + +public static class CorsServiceCollectionExtensions +{ + public const string DefaultPolicyName = "Mistruna"; + + public static IServiceCollection AddMistrunaCors( + this IServiceCollection services, + Action? configure = null, + string policyName = DefaultPolicyName) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new MistrunaCorsOptions(); + configure?.Invoke(options); + + if (options.AllowAnyOrigin && options.AllowCredentials) + { + throw new InvalidOperationException( + "CORS cannot allow credentials together with any origin."); + } + + services.AddCors(cors => cors.AddPolicy(policyName, policy => + { + ConfigureOrigins(policy, options); + ConfigureMethods(policy, options); + ConfigureHeaders(policy, options); + + if (options.AllowCredentials) + { + policy.AllowCredentials(); + } + })); + + return services; + } + + private static void ConfigureOrigins(CorsPolicyBuilder policy, MistrunaCorsOptions options) + { + if (options.AllowAnyOrigin || options.AllowedOrigins.Count == 0) + { + policy.AllowAnyOrigin(); + return; + } + + policy.WithOrigins(options.AllowedOrigins.ToArray()); + } + + private static void ConfigureMethods(CorsPolicyBuilder policy, MistrunaCorsOptions options) + { + if (options.AllowAnyMethod || options.AllowedMethods.Count == 0) + { + policy.AllowAnyMethod(); + return; + } + + policy.WithMethods(options.AllowedMethods.ToArray()); + } + + private static void ConfigureHeaders(CorsPolicyBuilder policy, MistrunaCorsOptions options) + { + if (options.AllowAnyHeader || options.AllowedHeaders.Count == 0) + { + policy.AllowAnyHeader(); + return; + } + + policy.WithHeaders(options.AllowedHeaders.ToArray()); + } +} diff --git a/src/Mistruna.Core.AspNetCore/Cors/MistrunaCorsOptions.cs b/src/Mistruna.Core.AspNetCore/Cors/MistrunaCorsOptions.cs new file mode 100644 index 0000000..a2110ba --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Cors/MistrunaCorsOptions.cs @@ -0,0 +1,18 @@ +namespace Mistruna.Core.AspNetCore.Cors; + +public sealed class MistrunaCorsOptions +{ + public bool AllowAnyOrigin { get; set; } = true; + + public bool AllowAnyMethod { get; set; } = true; + + public bool AllowAnyHeader { get; set; } = true; + + public bool AllowCredentials { get; set; } + + public IList AllowedOrigins { get; } = []; + + public IList AllowedMethods { get; } = []; + + public IList AllowedHeaders { get; } = []; +} diff --git a/src/Mistruna.Core.AspNetCore/DependencyInjection/ApplicationBuilderExtensions.cs b/src/Mistruna.Core.AspNetCore/DependencyInjection/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..d27645b --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/DependencyInjection/ApplicationBuilderExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Builder; + +namespace Mistruna.Core.AspNetCore.DependencyInjection; + +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseMistrunaExceptionHandler(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseExceptionHandler(); + } +} diff --git a/src/Mistruna.Core.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mistruna.Core.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..27ab36d --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.AspNetCore.ExceptionHandling; + +namespace Mistruna.Core.AspNetCore.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMistrunaAspNetCore(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddExceptionHandler(); + services.AddProblemDetails(); + + return services; + } +} diff --git a/src/Mistruna.Core.AspNetCore/ExceptionHandling/DbUpdateViolationDetector.cs b/src/Mistruna.Core.AspNetCore/ExceptionHandling/DbUpdateViolationDetector.cs new file mode 100644 index 0000000..145cd91 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/ExceptionHandling/DbUpdateViolationDetector.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; + +namespace Mistruna.Core.AspNetCore.ExceptionHandling; + +internal static class DbUpdateViolationDetector +{ + public static bool IsForeignKeyViolation(DbUpdateException exception, out string? constraint) + { + var message = exception.InnerException?.Message ?? string.Empty; + var isViolation = + message.Contains("foreign key constraint", StringComparison.OrdinalIgnoreCase) || + message.Contains("violates foreign key constraint", StringComparison.OrdinalIgnoreCase) || + message.Contains("FOREIGN KEY constraint failed", StringComparison.OrdinalIgnoreCase); + + constraint = isViolation ? ExtractConstraint(message) : null; + return isViolation; + } + + public static bool IsUniqueViolation(DbUpdateException exception, out string? constraint) + { + var message = exception.InnerException?.Message ?? string.Empty; + var isViolation = + message.Contains("unique constraint", StringComparison.OrdinalIgnoreCase) || + message.Contains("duplicate key", StringComparison.OrdinalIgnoreCase) || + message.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase); + + constraint = isViolation ? ExtractConstraint(message) : null; + return isViolation; + } + + private static string? ExtractConstraint(string message) + { + var marker = "constraint '"; + var start = message.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + return null; + } + + start += marker.Length; + var end = message.IndexOf('\'', start); + return end > start ? message[start..end] : null; + } +} diff --git a/src/Mistruna.Core.AspNetCore/ExceptionHandling/MistrunaExceptionHandler.cs b/src/Mistruna.Core.AspNetCore/ExceptionHandling/MistrunaExceptionHandler.cs new file mode 100644 index 0000000..fe825c0 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/ExceptionHandling/MistrunaExceptionHandler.cs @@ -0,0 +1,110 @@ +using FluentValidation; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Mistruna.Core.AspNetCore.Results; +using Mistruna.Core.Exceptions; + +namespace Mistruna.Core.AspNetCore.ExceptionHandling; + +public sealed class MistrunaExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + var mapping = Map(exception); + + if (mapping.StatusCode >= StatusCodes.Status500InternalServerError) + { + logger.LogError(exception, "Unhandled exception. TraceId: {TraceId}", httpContext.TraceIdentifier); + } + else + { + logger.LogWarning(exception, "Request failed with {ErrorCode}. TraceId: {TraceId}", + mapping.ErrorCode, httpContext.TraceIdentifier); + } + + var problem = exception.ToProblemDetails( + mapping.StatusCode, + mapping.Title, + mapping.ErrorCode, + httpContext.TraceIdentifier, + mapping.Details, + mapping.Detail); + + httpContext.Response.StatusCode = mapping.StatusCode; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = problem, + Exception = exception + }); + } + + private static ExceptionMapping Map(Exception exception) + { + return exception switch + { + ValidationException validation => new( + StatusCodes.Status400BadRequest, + "Validation failed", + "VALIDATION_ERROR", + validation.Errors.Select(error => new + { + error.PropertyName, + error.ErrorMessage, + error.ErrorCode + }).ToArray()), + BadRequestException badRequest => FromCore( + badRequest, StatusCodes.Status400BadRequest, "Bad request"), + NotFoundException notFound => FromCore( + notFound, StatusCodes.Status404NotFound, "Resource not found"), + UnauthorizedAccessException => new( + StatusCodes.Status401Unauthorized, "Unauthorized", "UNAUTHORIZED"), + ForbiddenAccessException forbidden => FromCore( + forbidden, StatusCodes.Status403Forbidden, "Forbidden"), + ConflictException conflict => FromCore( + conflict, StatusCodes.Status409Conflict, "Conflict"), + TimeoutException => new( + StatusCodes.Status408RequestTimeout, "Request timed out", "TIMEOUT"), + DbUpdateException dbUpdate when + DbUpdateViolationDetector.IsForeignKeyViolation(dbUpdate, out var foreignKey) => new( + StatusCodes.Status409Conflict, + "Foreign key constraint violation", + "FOREIGN_KEY_VIOLATION", + foreignKey, + "The operation conflicts with a related resource."), + DbUpdateException dbUpdate when + DbUpdateViolationDetector.IsUniqueViolation(dbUpdate, out var uniqueConstraint) => new( + StatusCodes.Status409Conflict, + "Unique constraint violation", + "UNIQUE_CONSTRAINT_VIOLATION", + uniqueConstraint, + "A resource with the provided values already exists."), + CoreException core => FromCore( + core, StatusCodes.Status400BadRequest, "Request failed"), + _ => new( + StatusCodes.Status500InternalServerError, + "Internal server error", + "INTERNAL_ERROR", + Detail: "An unexpected error occurred.") + }; + } + + private static ExceptionMapping FromCore(CoreException exception, int statusCode, string title) => + new(statusCode, title, exception.ErrorCode, exception.Details); + + private sealed record ExceptionMapping( + int StatusCode, + string Title, + string ErrorCode, + object? Details = null, + string? Detail = null); +} diff --git a/src/Mistruna.Core.AspNetCore/HealthChecks/DatabaseHealthCheck.cs b/src/Mistruna.Core.AspNetCore/HealthChecks/DatabaseHealthCheck.cs new file mode 100644 index 0000000..3ac6ca7 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/HealthChecks/DatabaseHealthCheck.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Mistruna.Core.AspNetCore.HealthChecks; + +internal sealed class DatabaseHealthCheck( + TDbContext dbContext, + ILogger> logger) : IHealthCheck + where TDbContext : DbContext +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + return await dbContext.Database.CanConnectAsync(cancellationToken) + ? HealthCheckResult.Healthy("Database is accessible") + : HealthCheckResult.Unhealthy("Cannot connect to database"); + } + catch (Exception exception) + { + logger.LogError(exception, "Database health check failed"); + return HealthCheckResult.Unhealthy("Database health check failed", exception); + } + } +} diff --git a/src/Mistruna.Core.AspNetCore/HealthChecks/HealthCheckServiceCollectionExtensions.cs b/src/Mistruna.Core.AspNetCore/HealthChecks/HealthCheckServiceCollectionExtensions.cs new file mode 100644 index 0000000..be4d055 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/HealthChecks/HealthCheckServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Mistruna.Core.AspNetCore.HealthChecks; + +public static class HealthCheckServiceCollectionExtensions +{ + public static IServiceCollection AddMistrunaHealthChecks( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var builder = services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]); + + configure?.Invoke(builder); + return services; + } + + public static IHealthChecksBuilder AddMistrunaDatabaseHealthCheck( + this IHealthChecksBuilder builder, + string name = "database", + HealthStatus? failureStatus = null, + IEnumerable? tags = null) + where TDbContext : DbContext + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.AddCheck>( + name, + failureStatus ?? HealthStatus.Unhealthy, + tags ?? ["ready", "db", "infrastructure"]); + } +} diff --git a/src/Mistruna.Core.AspNetCore/Mistruna.Core.AspNetCore.csproj b/src/Mistruna.Core.AspNetCore/Mistruna.Core.AspNetCore.csproj new file mode 100644 index 0000000..93c8101 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Mistruna.Core.AspNetCore.csproj @@ -0,0 +1,29 @@ + + + net10.0 + true + Mistruna.Core.AspNetCore + ProblemDetails exception handling, health/versioning/CORS/JWT helpers, and IP rate limiting for Mistruna. + Mistruna.Core.AspNetCore + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingApplicationBuilderExtensions.cs b/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingApplicationBuilderExtensions.cs new file mode 100644 index 0000000..c55c45f --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingApplicationBuilderExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Builder; + +namespace Mistruna.Core.AspNetCore.RateLimiting; + +public static class IpRateLimitingApplicationBuilderExtensions +{ + public static IApplicationBuilder UseMistrunaIpRateLimiting(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseMiddleware(); + } +} diff --git a/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs b/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs new file mode 100644 index 0000000..3ad9e83 --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/RateLimiting/IpRateLimitingServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Mistruna.Core.AspNetCore.RateLimiting; + +public static class IpRateLimitingServiceCollectionExtensions +{ + public static IServiceCollection AddMistrunaIpRateLimiting( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = services.AddOptions(); + if (configure is not null) + { + options.Configure(configure); + } + + options + .Validate(value => value.RequestsPerWindow > 0, "RequestsPerWindow must be positive.") + .Validate(value => value.WindowSeconds > 0, "WindowSeconds must be positive.") + .ValidateOnStart(); + + return services; + } +} diff --git a/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitOptions.cs b/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitOptions.cs new file mode 100644 index 0000000..e765eef --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitOptions.cs @@ -0,0 +1,12 @@ +namespace Mistruna.Core.AspNetCore.RateLimiting; + +public sealed class MistrunaIpRateLimitOptions +{ + public int RequestsPerWindow { get; set; } = 100; + + public int WindowSeconds { get; set; } = 60; + + public string KeyPrefix { get; set; } = "mistruna:ratelimit"; + + public bool IncludeHeaders { get; set; } = true; +} diff --git a/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitingMiddleware.cs b/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitingMiddleware.cs new file mode 100644 index 0000000..57b3b8e --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/RateLimiting/MistrunaIpRateLimitingMiddleware.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Mistruna.Core.AspNetCore.RateLimiting; + +internal sealed class MistrunaIpRateLimitingMiddleware( + RequestDelegate next, + IOptions options, + ILogger logger, + IConnectionMultiplexer? redis = null) +{ + private readonly ConcurrentDictionary _counters = new(); + + public async Task InvokeAsync(HttpContext context) + { + var settings = options.Value; + var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var key = $"{settings.KeyPrefix}:{clientIp}"; + var window = TimeSpan.FromSeconds(settings.WindowSeconds); + + var result = redis is { IsConnected: true } + ? await CheckRedisAsync(key, window, settings, redis) + : CheckMemory(key, window, settings); + + if (settings.IncludeHeaders) + { + context.Response.Headers["X-RateLimit-Limit"] = settings.RequestsPerWindow.ToString(); + context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, result.Remaining).ToString(); + context.Response.Headers["X-RateLimit-Reset"] = result.RetryAfter.ToString(); + } + + if (result.Count <= settings.RequestsPerWindow) + { + await next(context); + return; + } + + logger.LogWarning("IP rate limit exceeded for {ClientIp}", clientIp); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.ContentType = "application/problem+json"; + context.Response.Headers.RetryAfter = result.RetryAfter.ToString(); + + var problem = new ProblemDetails + { + Status = StatusCodes.Status429TooManyRequests, + Title = "Too many requests", + Detail = "Please try again later." + }; + problem.Extensions["errorCode"] = "RATE_LIMIT_EXCEEDED"; + problem.Extensions["traceId"] = context.TraceIdentifier; + problem.Extensions["retryAfterSeconds"] = result.RetryAfter; + + await context.Response.WriteAsJsonAsync(problem, context.RequestAborted); + } + + private async Task CheckRedisAsync( + string key, + TimeSpan window, + MistrunaIpRateLimitOptions settings, + IConnectionMultiplexer connection) + { + try + { + var database = connection.GetDatabase(); + var count = await database.StringIncrementAsync(key); + if (count == 1) + { + await database.KeyExpireAsync(key, window); + } + + var ttl = await database.KeyTimeToLiveAsync(key); + var retryAfter = Math.Max(0, (long)(ttl?.TotalSeconds ?? settings.WindowSeconds)); + return new(count, settings.RequestsPerWindow - count, retryAfter); + } + catch (RedisException exception) + { + logger.LogWarning(exception, "Redis rate limit check failed; using in-memory counters"); + return CheckMemory(key, window, settings); + } + } + + private RateLimitResult CheckMemory( + string key, + TimeSpan window, + MistrunaIpRateLimitOptions settings) + { + var now = DateTimeOffset.UtcNow; + var entry = _counters.AddOrUpdate( + key, + _ => (1, now), + (_, current) => now - current.WindowStart >= window + ? (1, now) + : (current.Count + 1, current.WindowStart)); + + var retryAfter = Math.Max(0, (long)(window - (now - entry.WindowStart)).TotalSeconds); + return new(entry.Count, settings.RequestsPerWindow - entry.Count, retryAfter); + } + + private readonly record struct RateLimitResult(long Count, long Remaining, long RetryAfter); +} diff --git a/src/Mistruna.Core.AspNetCore/Results/ProblemDetailsExtensions.cs b/src/Mistruna.Core.AspNetCore/Results/ProblemDetailsExtensions.cs new file mode 100644 index 0000000..919be4c --- /dev/null +++ b/src/Mistruna.Core.AspNetCore/Results/ProblemDetailsExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Mistruna.Core.AspNetCore.Results; + +public static class ProblemDetailsExtensions +{ + public static ProblemDetails ToProblemDetails( + this Exception exception, + int statusCode, + string title, + string errorCode, + string traceId, + object? details = null, + string? detail = null) + { + var problem = new ProblemDetails + { + Status = statusCode, + Title = title, + Detail = detail ?? exception.Message + }; + + problem.Extensions["errorCode"] = errorCode; + problem.Extensions["traceId"] = traceId; + + if (details is not null) + { + problem.Extensions["details"] = details; + } + + return problem; + } +} diff --git a/src/Mistruna.Core.Caching.Redis/Mistruna.Core.Caching.Redis.csproj b/src/Mistruna.Core.Caching.Redis/Mistruna.Core.Caching.Redis.csproj new file mode 100644 index 0000000..696d6ea --- /dev/null +++ b/src/Mistruna.Core.Caching.Redis/Mistruna.Core.Caching.Redis.csproj @@ -0,0 +1,52 @@ + + + + + net10.0 + + true + + Mistruna.Core.Caching.Redis + + Redis cache implementation and health check for Mistruna. + + Mistruna.Core.Caching.Redis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mistruna.Core/Caching/RedisCacheService.cs b/src/Mistruna.Core.Caching.Redis/RedisCacheService.cs similarity index 95% rename from src/Mistruna.Core/Caching/RedisCacheService.cs rename to src/Mistruna.Core.Caching.Redis/RedisCacheService.cs index eba849e..1356125 100644 --- a/src/Mistruna.Core/Caching/RedisCacheService.cs +++ b/src/Mistruna.Core.Caching.Redis/RedisCacheService.cs @@ -1,10 +1,11 @@ using System.Collections.Concurrent; using System.Text.Json; using Microsoft.Extensions.Logging; -using Mistruna.Core.Contracts.Base.Infrastructure; +using Microsoft.Extensions.Options; +using Mistruna.Core.Abstractions.Persistence; using StackExchange.Redis; -namespace Mistruna.Core.Caching; +namespace Mistruna.Core.Caching.Redis; /// /// Redis-backed implementation of . @@ -14,11 +15,11 @@ namespace Mistruna.Core.Caching; public sealed class RedisCacheService( IConnectionMultiplexer connection, ILogger logger, - CacheOptions? options = null) : ICacheService + IOptions options) : ICacheService { private static readonly ConcurrentDictionary Locks = new(); private readonly IDatabase _database = connection.GetDatabase(); - private readonly CacheOptions _options = options ?? new CacheOptions(); + private readonly CacheOptions _options = options.Value; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, diff --git a/src/Mistruna.Core/HealthChecks/RedisHealthCheck.cs b/src/Mistruna.Core.Caching.Redis/RedisHealthCheck.cs similarity index 81% rename from src/Mistruna.Core/HealthChecks/RedisHealthCheck.cs rename to src/Mistruna.Core.Caching.Redis/RedisHealthCheck.cs index 4005eb4..e4501c0 100644 --- a/src/Mistruna.Core/HealthChecks/RedisHealthCheck.cs +++ b/src/Mistruna.Core.Caching.Redis/RedisHealthCheck.cs @@ -2,14 +2,11 @@ using Microsoft.Extensions.Logging; using StackExchange.Redis; -namespace Mistruna.Core.HealthChecks; +namespace Mistruna.Core.Caching.Redis; /// /// Health check that verifies Redis connectivity by issuing a PING command. -/// Reports when Redis responds, otherwise . /// -/// The Redis connection multiplexer. -/// The logger instance. public sealed class RedisHealthCheck( IConnectionMultiplexer connection, ILogger logger) : IHealthCheck diff --git a/src/Mistruna.Core.Caching.Redis/ServiceCollectionExtensions.cs b/src/Mistruna.Core.Caching.Redis/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..598dca6 --- /dev/null +++ b/src/Mistruna.Core.Caching.Redis/ServiceCollectionExtensions.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Mistruna.Core.Abstractions.Persistence; +using StackExchange.Redis; + +namespace Mistruna.Core.Caching.Redis; + +/// Extension methods for registering Redis caching and health check services. +public static class ServiceCollectionExtensions +{ + /// + /// Registers Redis caching services using configuration from + /// Mistruna:Redis or ConnectionStrings:Redis. + /// + public static IServiceCollection AddMistrunaRedis( + this IServiceCollection services, + IConfiguration configuration, + Action? configureCacheOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var connectionString = ResolveConnectionString(configuration); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "Redis connection string is not configured. Set Mistruna:Redis:ConnectionString or ConnectionStrings:Redis."); + } + + services.Configure(configuration.GetSection("Mistruna:Redis")); + if (configureCacheOptions is not null) + { + services.PostConfigure(configureCacheOptions); + } + + services.TryAddSingleton(_ => + ConnectionMultiplexer.Connect(connectionString)); + + services.TryAddSingleton(); + + return services; + } + + /// Adds a Redis health check to the health checks builder. + public static IHealthChecksBuilder AddRedisHealthCheck( + this IHealthChecksBuilder builder, + string name = "redis", + HealthStatus? failureStatus = null, + IEnumerable? tags = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.AddCheck( + name, + failureStatus ?? HealthStatus.Unhealthy, + tags ?? ["redis", "cache", "infrastructure"]); + } + + private static string? ResolveConnectionString(IConfiguration configuration) + { + var mistrunaConnectionString = configuration["Mistruna:Redis:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(mistrunaConnectionString)) + { + return mistrunaConnectionString; + } + + return configuration.GetConnectionString("Redis"); + } +} diff --git a/src/Mistruna.Core.Contracts/Attributes/PrecisionAttribute.cs b/src/Mistruna.Core.Contracts/Attributes/PrecisionAttribute.cs deleted file mode 100644 index 2179cfb..0000000 --- a/src/Mistruna.Core.Contracts/Attributes/PrecisionAttribute.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Mistruna.Core.Contracts.Attributes; - -/// -/// Specifies the precision and scale for decimal properties. -/// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public class PrecisionAttribute : Attribute -{ - /// - /// Total number of digits (including digits after decimal point). - /// - public int Precision { get; } - - /// - /// Number of digits after the decimal point. - /// - public int Scale { get; } - - /// - /// Creates a new PrecisionAttribute with the specified precision and scale. - /// - /// Total number of digits. - /// Number of decimal places. - public PrecisionAttribute(int precision, int scale) - { - if (precision < 1) - { - throw new ArgumentOutOfRangeException(nameof(precision), "Precision must be at least 1."); - } - - if (scale < 0) - { - throw new ArgumentOutOfRangeException(nameof(scale), "Scale must be non-negative."); - } - - if (scale > precision) - { - throw new ArgumentOutOfRangeException(nameof(scale), "Scale cannot be greater than precision."); - } - - Precision = precision; - Scale = scale; - } - - /// - /// Gets the maximum value allowed for the specified precision and scale. - /// - public decimal GetMaxValue() - { - var integerDigits = Precision - Scale; - var maxIntPart = (decimal)Math.Pow(10, integerDigits) - 1; - var maxFracPart = Scale > 0 ? (decimal)(Math.Pow(10, Scale) - 1) / (decimal)Math.Pow(10, Scale) : 0m; - return maxIntPart + maxFracPart; - } - - /// - /// Gets the minimum value allowed (negative of max value). - /// - public decimal GetMinValue() => -GetMaxValue(); -} diff --git a/src/Mistruna.Core.Contracts/Base/Entities/CommonValueObjects.cs b/src/Mistruna.Core.Contracts/Base/Entities/CommonValueObjects.cs deleted file mode 100644 index c94b8cf..0000000 --- a/src/Mistruna.Core.Contracts/Base/Entities/CommonValueObjects.cs +++ /dev/null @@ -1,446 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Mistruna.Core.Contracts.Base.Entities; - -/// -/// Represents an email address value object. -/// -public sealed class Email : ValueObject -{ - private static readonly Regex EmailRegex = new( - @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// - /// Gets the email address value. - /// - public string Value { get; } - - private Email(string value) - { - Value = value.ToLowerInvariant(); - } - - /// - /// Creates a new Email instance. - /// - /// The email address string. - /// A new Email value object. - /// Thrown when the email format is invalid. - public static Email Create(string email) - { - if (string.IsNullOrWhiteSpace(email)) - throw new ArgumentException("Email cannot be null or empty.", nameof(email)); - - if (!EmailRegex.IsMatch(email)) - throw new ArgumentException("Invalid email format.", nameof(email)); - - return new Email(email); - } - - /// - /// Tries to create a new Email instance. - /// - /// The email address string. - /// The created Email, or null if invalid. - /// True if the email was valid, otherwise false. - public static bool TryCreate(string email, out Email? result) - { - result = null; - - if (string.IsNullOrWhiteSpace(email) || !EmailRegex.IsMatch(email)) - return false; - - result = new Email(email); - return true; - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Value; - } - - /// - public override string ToString() => Value; - - /// - /// Implicit conversion to string. - /// - public static implicit operator string(Email email) => email.Value; -} - -/// -/// Represents a phone number value object. -/// -public sealed class PhoneNumber : ValueObject -{ - private static readonly Regex PhoneRegex = new( - @"^\+?[1-9]\d{1,14}$", - RegexOptions.Compiled); - - /// - /// Gets the phone number value (digits only, with optional leading +). - /// - public string Value { get; } - - private PhoneNumber(string value) - { - Value = value; - } - - /// - /// Creates a new PhoneNumber instance. - /// - /// The phone number string. - /// A new PhoneNumber value object. - /// Thrown when the phone number format is invalid. - public static PhoneNumber Create(string phoneNumber) - { - if (string.IsNullOrWhiteSpace(phoneNumber)) - throw new ArgumentException("Phone number cannot be null or empty.", nameof(phoneNumber)); - - // Remove common formatting characters - var normalized = Regex.Replace(phoneNumber, @"[\s\-\(\)\.]", ""); - - if (!PhoneRegex.IsMatch(normalized)) - throw new ArgumentException("Invalid phone number format.", nameof(phoneNumber)); - - return new PhoneNumber(normalized); - } - - /// - /// Tries to create a new PhoneNumber instance. - /// - /// The phone number string. - /// The created PhoneNumber, or null if invalid. - /// True if the phone number was valid, otherwise false. - public static bool TryCreate(string phoneNumber, out PhoneNumber? result) - { - result = null; - - if (string.IsNullOrWhiteSpace(phoneNumber)) - return false; - - var normalized = Regex.Replace(phoneNumber, @"[\s\-\(\)\.]", ""); - - if (!PhoneRegex.IsMatch(normalized)) - return false; - - result = new PhoneNumber(normalized); - return true; - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Value; - } - - /// - public override string ToString() => Value; - - /// - /// Implicit conversion to string. - /// - public static implicit operator string(PhoneNumber phone) => phone.Value; -} - -/// -/// Represents a money value object. -/// -public sealed class Money : ValueObject, IComparable -{ - /// - /// Gets the amount. - /// - public decimal Amount { get; } - - /// - /// Gets the currency code. - /// - public string Currency { get; } - - private Money(decimal amount, string currency) - { - Amount = amount; - Currency = currency.ToUpperInvariant(); - } - - /// - /// Creates a new Money instance. - /// - /// The amount. - /// The currency code (e.g., USD, EUR). - /// A new Money value object. - public static Money Create(decimal amount, string currency) - { - if (string.IsNullOrWhiteSpace(currency)) - throw new ArgumentException("Currency cannot be null or empty.", nameof(currency)); - - if (currency.Length != 3) - throw new ArgumentException("Currency must be a 3-letter code.", nameof(currency)); - - return new Money(Math.Round(amount, 2), currency); - } - - /// - /// Creates a zero Money instance. - /// - /// The currency code. - /// A Money with zero amount. - public static Money Zero(string currency) => Create(0, currency); - - /// - /// Adds two Money instances. - /// - public static Money operator +(Money left, Money right) - { - EnsureSameCurrency(left, right); - return Create(left.Amount + right.Amount, left.Currency); - } - - /// - /// Subtracts two Money instances. - /// - public static Money operator -(Money left, Money right) - { - EnsureSameCurrency(left, right); - return Create(left.Amount - right.Amount, left.Currency); - } - - /// - /// Multiplies Money by a scalar. - /// - public static Money operator *(Money money, decimal multiplier) - { - return Create(money.Amount * multiplier, money.Currency); - } - - /// - /// Divides Money by a scalar. - /// - public static Money operator /(Money money, decimal divisor) - { - if (divisor == 0) - throw new DivideByZeroException(); - - return Create(money.Amount / divisor, money.Currency); - } - - /// - /// Less than operator. - /// - public static bool operator <(Money left, Money right) - { - EnsureSameCurrency(left, right); - return left.Amount < right.Amount; - } - - /// - /// Greater than operator. - /// - public static bool operator >(Money left, Money right) - { - EnsureSameCurrency(left, right); - return left.Amount > right.Amount; - } - - /// - /// Less than or equal operator. - /// - public static bool operator <=(Money left, Money right) - { - EnsureSameCurrency(left, right); - return left.Amount <= right.Amount; - } - - /// - /// Greater than or equal operator. - /// - public static bool operator >=(Money left, Money right) - { - EnsureSameCurrency(left, right); - return left.Amount >= right.Amount; - } - - /// - public int CompareTo(Money? other) - { - if (other is null) - return 1; - - EnsureSameCurrency(this, other); - return Amount.CompareTo(other.Amount); - } - - private static void EnsureSameCurrency(Money left, Money right) - { - if (left.Currency != right.Currency) - throw new InvalidOperationException( - $"Cannot perform operation on different currencies: {left.Currency} and {right.Currency}"); - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Amount; - yield return Currency; - } - - /// - public override string ToString() => $"{Amount:N2} {Currency}"; -} - -/// -/// Represents an address value object. -/// -public sealed class Address : ValueObject -{ - /// - /// Gets the street address. - /// - public string Street { get; } - - /// - /// Gets the city. - /// - public string City { get; } - - /// - /// Gets the state or province. - /// - public string? State { get; } - - /// - /// Gets the postal code. - /// - public string PostalCode { get; } - - /// - /// Gets the country. - /// - public string Country { get; } - - private Address(string street, string city, string? state, string postalCode, string country) - { - Street = street; - City = city; - State = state; - PostalCode = postalCode; - Country = country; - } - - /// - /// Creates a new Address instance. - /// - public static Address Create( - string street, - string city, - string postalCode, - string country, - string? state = null) - { - if (string.IsNullOrWhiteSpace(street)) - throw new ArgumentException("Street cannot be null or empty.", nameof(street)); - - if (string.IsNullOrWhiteSpace(city)) - throw new ArgumentException("City cannot be null or empty.", nameof(city)); - - if (string.IsNullOrWhiteSpace(postalCode)) - throw new ArgumentException("Postal code cannot be null or empty.", nameof(postalCode)); - - if (string.IsNullOrWhiteSpace(country)) - throw new ArgumentException("Country cannot be null or empty.", nameof(country)); - - return new Address(street.Trim(), city.Trim(), state?.Trim(), postalCode.Trim(), country.Trim()); - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Street; - yield return City; - yield return State; - yield return PostalCode; - yield return Country; - } - - /// - public override string ToString() - { - var parts = new List { Street, City }; - - if (!string.IsNullOrWhiteSpace(State)) - parts.Add(State!); - - parts.Add(PostalCode); - parts.Add(Country); - - return string.Join(", ", parts); - } -} - -/// -/// Represents a date range value object. -/// -public sealed class DateRange : ValueObject -{ - /// - /// Gets the start date. - /// - public DateTime Start { get; } - - /// - /// Gets the end date. - /// - public DateTime End { get; } - - /// - /// Gets the duration of the range. - /// - public TimeSpan Duration => End - Start; - - private DateRange(DateTime start, DateTime end) - { - Start = start; - End = end; - } - - /// - /// Creates a new DateRange instance. - /// - public static DateRange Create(DateTime start, DateTime end) - { - if (end < start) - throw new ArgumentException("End date must be greater than or equal to start date.", nameof(end)); - - return new DateRange(start, end); - } - - /// - /// Checks if a date falls within this range. - /// - public bool Contains(DateTime date) - { - return date >= Start && date <= End; - } - - /// - /// Checks if this range overlaps with another range. - /// - public bool Overlaps(DateRange other) - { - return Start <= other.End && End >= other.Start; - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Start; - yield return End; - } - - /// - public override string ToString() => $"{Start:d} - {End:d}"; -} diff --git a/src/Mistruna.Core.Contracts/Base/Entities/Entity.cs b/src/Mistruna.Core.Contracts/Base/Entities/Entity.cs deleted file mode 100644 index 0faef0c..0000000 --- a/src/Mistruna.Core.Contracts/Base/Entities/Entity.cs +++ /dev/null @@ -1,254 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Entities; - -/// -/// Interface for entities with a unique identifier. -/// -/// The type of the identifier. -public interface IEntity where TId : IEquatable -{ - /// - /// Gets the unique identifier. - /// - TId Id { get; } -} - -/// -/// Interface for entities with audit information. -/// -public interface IAuditableEntity -{ - /// - /// Gets or sets the creation timestamp. - /// - DateTime CreatedAt { get; set; } - - /// - /// Gets or sets who created the entity. - /// - string? CreatedBy { get; set; } - - /// - /// Gets or sets the last modification timestamp. - /// - DateTime? ModifiedAt { get; set; } - - /// - /// Gets or sets who last modified the entity. - /// - string? ModifiedBy { get; set; } -} - -/// -/// Interface for soft-deletable entities. -/// -public interface ISoftDeletable -{ - /// - /// Gets or sets whether the entity is deleted. - /// - bool IsDeleted { get; set; } - - /// - /// Gets or sets the deletion timestamp. - /// - DateTime? DeletedAt { get; set; } - - /// - /// Gets or sets who deleted the entity. - /// - string? DeletedBy { get; set; } -} - -/// -/// Base class for entities with a unique identifier. -/// -/// The type of the identifier. -public abstract class Entity : IEntity, IEquatable> - where TId : IEquatable -{ - /// - public virtual TId Id { get; protected set; } = default!; - - /// - /// Determines whether the specified entity is equal to the current entity. - /// - /// The entity to compare with. - /// True if equal, otherwise false. - public bool Equals(Entity? other) - { - if (other is null) - return false; - - if (ReferenceEquals(this, other)) - return true; - - if (GetType() != other.GetType()) - return false; - - if (Id.Equals(default!) || other.Id.Equals(default!)) - return false; - - return Id.Equals(other.Id); - } - - /// - public override bool Equals(object? obj) - { - return obj is Entity entity && Equals(entity); - } - - /// - public override int GetHashCode() - { - return (GetType().GetHashCode() * 907) + Id.GetHashCode(); - } - - /// - /// Equality operator. - /// - public static bool operator ==(Entity? left, Entity? right) - { - if (left is null && right is null) - return true; - - if (left is null || right is null) - return false; - - return left.Equals(right); - } - - /// - /// Inequality operator. - /// - public static bool operator !=(Entity? left, Entity? right) - { - return !(left == right); - } -} - -/// -/// Base class for entities with a GUID identifier. -/// -public abstract class Entity : Entity -{ - /// - /// Initializes a new instance with a new GUID. - /// - protected Entity() - { - Id = Guid.NewGuid(); - } - - /// - /// Initializes a new instance with the specified GUID. - /// - /// The unique identifier. - protected Entity(Guid id) - { - Id = id; - } -} - -/// -/// Base class for auditable entities. -/// -/// The type of the identifier. -public abstract class AuditableEntity : Entity, IAuditableEntity - where TId : IEquatable -{ - /// - public DateTime CreatedAt { get; set; } - - /// - public string? CreatedBy { get; set; } - - /// - public DateTime? ModifiedAt { get; set; } - - /// - public string? ModifiedBy { get; set; } -} - -/// -/// Base class for auditable entities with a GUID identifier. -/// -public abstract class AuditableEntity : AuditableEntity -{ - /// - /// Initializes a new instance with a new GUID. - /// - protected AuditableEntity() - { - Id = Guid.NewGuid(); - } - - /// - /// Initializes a new instance with the specified GUID. - /// - /// The unique identifier. - protected AuditableEntity(Guid id) - { - Id = id; - } -} - -/// -/// Base class for soft-deletable auditable entities. -/// -/// The type of the identifier. -public abstract class SoftDeletableEntity : AuditableEntity, ISoftDeletable - where TId : IEquatable -{ - /// - public bool IsDeleted { get; set; } - - /// - public DateTime? DeletedAt { get; set; } - - /// - public string? DeletedBy { get; set; } - - /// - /// Marks the entity as deleted. - /// - /// Who is deleting the entity. - public virtual void Delete(string? deletedBy = null) - { - IsDeleted = true; - DeletedAt = DateTime.UtcNow; - DeletedBy = deletedBy; - } - - /// - /// Restores the entity (undeletes it). - /// - public virtual void Restore() - { - IsDeleted = false; - DeletedAt = null; - DeletedBy = null; - } -} - -/// -/// Base class for soft-deletable auditable entities with a GUID identifier. -/// -public abstract class SoftDeletableEntity : SoftDeletableEntity -{ - /// - /// Initializes a new instance with a new GUID. - /// - protected SoftDeletableEntity() - { - Id = Guid.NewGuid(); - } - - /// - /// Initializes a new instance with the specified GUID. - /// - /// The unique identifier. - protected SoftDeletableEntity(Guid id) - { - Id = id; - } -} diff --git a/src/Mistruna.Core.Contracts/Base/Entities/ValueObject.cs b/src/Mistruna.Core.Contracts/Base/Entities/ValueObject.cs deleted file mode 100644 index e295204..0000000 --- a/src/Mistruna.Core.Contracts/Base/Entities/ValueObject.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Entities; - -/// -/// Base class for value objects. Value objects are immutable and compared by their values. -/// -public abstract class ValueObject : IEquatable -{ - /// - /// Gets the atomic values that make up this value object. - /// - /// An enumerable of the atomic values. - protected abstract IEnumerable GetEqualityComponents(); - - /// - public bool Equals(ValueObject? other) - { - return Equals((object?)other); - } - - /// - public override bool Equals(object? obj) - { - if (obj is null || obj.GetType() != GetType()) - { - return false; - } - - var other = (ValueObject)obj; - - return GetEqualityComponents() - .SequenceEqual(other.GetEqualityComponents()); - } - - /// - public override int GetHashCode() - { - return GetEqualityComponents() - .Select(x => x?.GetHashCode() ?? 0) - .Aggregate((x, y) => x ^ y); - } - - /// - /// Equality operator. - /// - public static bool operator ==(ValueObject? left, ValueObject? right) - { - if (left is null && right is null) - return true; - - if (left is null || right is null) - return false; - - return left.Equals(right); - } - - /// - /// Inequality operator. - /// - public static bool operator !=(ValueObject? left, ValueObject? right) - { - return !(left == right); - } -} - -/// -/// A value object that wraps a single value. -/// -/// The type of the value. -public abstract class SingleValueObject : ValueObject, IComparable> - where T : IComparable -{ - /// - /// Gets the wrapped value. - /// - public T Value { get; } - - /// - /// Initializes a new instance. - /// - /// The value to wrap. - protected SingleValueObject(T value) - { - Value = value; - } - - /// - protected override IEnumerable GetEqualityComponents() - { - yield return Value; - } - - /// - public int CompareTo(SingleValueObject? other) - { - return other is null ? 1 : Value.CompareTo(other.Value); - } - - /// - public override string ToString() - { - return Value?.ToString() ?? string.Empty; - } - - /// - /// Implicit conversion to the wrapped type. - /// - /// The value object. - public static implicit operator T(SingleValueObject valueObject) - { - return valueObject.Value; - } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IAuditable.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IAuditable.cs deleted file mode 100644 index 623c9ed..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IAuditable.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents an auditable entity with user tracking. -/// -public interface IAuditable : IEntity -{ - /// Identifier of the user who created the entity. - Guid CreatedBy { get; set; } - - /// Identifier of the user who last modified the entity. - Guid? ModifiedBy { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/ICacheService.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/ICacheService.cs deleted file mode 100644 index 5f995fc..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/ICacheService.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents a cache service for storing and retrieving cached data. -/// -/// -/// Implement this interface to provide distributed or in-memory caching capabilities. -/// -/// -/// -/// // Usage with Redis or Memory Cache -/// var user = await cacheService.GetOrCreateAsync( -/// $"user:{userId}", -/// async () => await userRepository.GetByIdAsync(userId), -/// TimeSpan.FromMinutes(10)); -/// -/// -public interface ICacheService -{ - /// - /// Gets a value from the cache. - /// - /// The type of the cached value. - /// The cache key. - /// A cancellation token. - /// The cached value or default if not found. - Task GetAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Sets a value in the cache. - /// - /// The type of the value to cache. - /// The cache key. - /// The value to cache. - /// The expiration time. - /// A cancellation token. - Task SetAsync( - string key, - T value, - TimeSpan? expiration = null, - CancellationToken cancellationToken = default); - - /// - /// Gets a value from the cache or creates it if it doesn't exist. - /// - /// The type of the cached value. - /// The cache key. - /// The factory function to create the value if not found. - /// The expiration time. - /// A cancellation token. - /// The cached or newly created value. - Task GetOrCreateAsync( - string key, - Func> factory, - TimeSpan? expiration = null, - CancellationToken cancellationToken = default); - - /// - /// Removes a value from the cache. - /// - /// The cache key. - /// A cancellation token. - Task RemoveAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Removes all values matching a pattern from the cache. - /// - /// The pattern to match (e.g., "user:*"). - /// A cancellation token. - Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default); - - /// - /// Checks if a key exists in the cache. - /// - /// The cache key. - /// A cancellation token. - /// True if the key exists. - Task ExistsAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Refreshes the expiration time of a cached item. - /// - /// The cache key. - /// The new expiration time. - /// A cancellation token. - Task RefreshAsync( - string key, - TimeSpan expiration, - CancellationToken cancellationToken = default); -} - -/// -/// Cache options for configuring cache behavior. -/// -public class CacheOptions -{ - /// - /// Gets or sets the default expiration time. - /// - public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Gets or sets the cache key prefix. - /// - public string KeyPrefix { get; set; } = string.Empty; - - /// - /// Gets or sets a value indicating whether to use sliding expiration. - /// - public bool UseSlidingExpiration { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IDomainEvent.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IDomainEvent.cs deleted file mode 100644 index 1d03a66..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IDomainEvent.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Marker interface for domain events. -/// -/// -/// Domain events represent something important that happened in the domain. -/// They are used to notify other parts of the system about changes. -/// -/// -/// -/// public class UserCreatedEvent : IDomainEvent -/// { -/// public Guid UserId { get; init; } -/// public string Email { get; init; } -/// public DateTime OccurredAt { get; } = DateTime.UtcNow; -/// } -/// -/// -public interface IDomainEvent -{ - /// - /// Gets the date and time when the event occurred. - /// - DateTime OccurredAt { get; } -} - -/// -/// Interface for handling domain events. -/// -/// The type of domain event. -public interface IDomainEventHandler where TEvent : IDomainEvent -{ - /// - /// Handles the domain event. - /// - /// The event to handle. - /// A cancellation token. - System.Threading.Tasks.Task HandleAsync(TEvent domainEvent, System.Threading.CancellationToken cancellationToken = default); -} - -/// -/// Interface for publishing domain events. -/// -public interface IDomainEventDispatcher -{ - /// - /// Dispatches a domain event to all registered handlers. - /// - /// The type of event. - /// The event to dispatch. - /// A cancellation token. - System.Threading.Tasks.Task DispatchAsync(TEvent domainEvent, System.Threading.CancellationToken cancellationToken = default) - where TEvent : IDomainEvent; -} - -/// -/// Base class for domain entities that raise events. -/// -public abstract class AggregateRoot : IEntity -{ - private readonly List _domainEvents = new(); - - #region IEntity - - /// - public Guid Id { get; set; } - - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - public DateTimeOffset? UpdatedAt { get; set; } - - #endregion - - /// - /// Gets the domain events that have been raised. - /// - public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); - - /// - /// Adds a domain event to be published. - /// - /// The domain event. - protected void AddDomainEvent(IDomainEvent domainEvent) - { - _domainEvents.Add(domainEvent); - } - - /// - /// Removes a domain event. - /// - /// The domain event to remove. - protected void RemoveDomainEvent(IDomainEvent domainEvent) - { - _domainEvents.Remove(domainEvent); - } - - /// - /// Clears all domain events. - /// - public void ClearDomainEvents() - { - _domainEvents.Clear(); - } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IEntity.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IEntity.cs deleted file mode 100644 index 236ceb4..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IEntity.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents the base entity with audit timestamps. -/// -public interface IEntity -{ - /// Entity identifier. - Guid Id { get; set; } - - /// Date when the entity was created (UTC). - DateTimeOffset CreatedAt { get; set; } - - /// Date when the entity was last modified (UTC). - DateTimeOffset? UpdatedAt { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IGenericRepository.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IGenericRepository.cs deleted file mode 100644 index cadd242..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IGenericRepository.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Linq.Expressions; - -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Read-only repository contract for entities. -/// -/// The entity type. -public interface IReadRepository where T : class, IEntity -{ - /// - /// Returns the entity by identifier. - /// - ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default); - - /// - /// Returns all entities. - /// - ValueTask> GetAllAsync(CancellationToken cancellationToken = default); - - /// - /// Finds entities matching the predicate. - /// - Task> FindAsync( - Expression> predicate, - CancellationToken cancellationToken = default); - - /// - /// Finds entities matching the specification. - /// - Task> FindAsync( - ISpecification specification, - CancellationToken cancellationToken = default); - - /// - /// Finds a single entity matching the specification. - /// - Task FindOneAsync( - ISpecification specification, - CancellationToken cancellationToken = default); - - /// - /// Counts entities matching the specification. - /// - Task CountAsync( - ISpecification specification, - CancellationToken cancellationToken = default); - - /// - /// Counts entities matching the predicate. - /// - Task CountAsync( - Expression>? predicate = null, - CancellationToken cancellationToken = default); - - /// - /// Checks if any entity matches the predicate. - /// - Task AnyAsync( - Expression> predicate, - CancellationToken cancellationToken = default); - - /// - /// Returns a queryable for advanced queries. - /// - IQueryable AsQueryable(); -} - -/// -/// Write-only repository contract for entities. -/// -/// The entity type. -/// -/// Write operations only update the current persistence context. Persist changes through . -/// -public interface IWriteRepository where T : class, IEntity -{ - /// - /// Asynchronously adds an entity to the repository. - /// - ValueTask AddAsync(T entity, CancellationToken cancellationToken = default); - - /// - /// Asynchronously adds multiple entities to the repository. - /// - Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); - - /// - /// Updates the entity. - /// - Task UpdateAsync(T entity, CancellationToken cancellationToken = default); - - /// - /// Updates multiple entities. - /// - Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); - - /// - /// Deletes the entity. - /// - Task DeleteAsync(T entity, CancellationToken cancellationToken = default); - - /// - /// Deletes multiple entities. - /// - Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); -} - -/// -/// Universal repository contract for entities. -/// -/// The entity type. -/// -/// Combines read and write operations. Prefer or -/// in consumers that only need one side of the contract. -/// -public interface IGenericRepository : IReadRepository, IWriteRepository - where T : class, IEntity; \ No newline at end of file diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IResponse.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IResponse.cs deleted file mode 100644 index 67539d0..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents a standard response interface. -/// -public interface IResponse -{ - /// Optional response message. - string? Message { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/ISpecification.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/ISpecification.cs deleted file mode 100644 index fa009bf..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/ISpecification.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System.Linq.Expressions; - -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents a specification pattern for defining query criteria. -/// -/// The type of entity. -/// -/// The Specification pattern encapsulates query logic that can be combined and reused. -/// -/// -/// -/// public class ActiveUsersSpec : Specification<User> -/// { -/// public override Expression<Func<User, bool>> ToExpression() -/// => user => user.IsActive && !user.IsDeleted; -/// } -/// -/// var activeUsers = await repository.FindAsync(new ActiveUsersSpec()); -/// -/// -public interface ISpecification where T : class -{ - /// - /// Gets the criteria expression for filtering. - /// - Expression>? Criteria { get; } - - /// - /// Gets the include expressions for eager loading. - /// - List>> Includes { get; } - - /// - /// Gets the string-based include expressions for eager loading. - /// - List IncludeStrings { get; } - - /// - /// Gets the order by expression. - /// - Expression>? OrderBy { get; } - - /// - /// Gets the order by descending expression. - /// - Expression>? OrderByDescending { get; } - - /// - /// Gets the number of items to take. - /// - int? Take { get; } - - /// - /// Gets the number of items to skip. - /// - int? Skip { get; } - - /// - /// Gets a value indicating whether paging is enabled. - /// - bool IsPagingEnabled { get; } - - /// - /// Gets a value indicating whether to use split queries. - /// - bool IsSplitQuery { get; } - - /// - /// Gets a value indicating whether to use no tracking. - /// - bool IsNoTracking { get; } -} - -/// -/// Base implementation of the specification pattern. -/// -/// The type of entity. -public abstract class Specification : ISpecification where T : class -{ - /// - public Expression>? Criteria { get; private set; } - - /// - public List>> Includes { get; } = new(); - - /// - public List IncludeStrings { get; } = new(); - - /// - public Expression>? OrderBy { get; private set; } - - /// - public Expression>? OrderByDescending { get; private set; } - - /// - public int? Take { get; private set; } - - /// - public int? Skip { get; private set; } - - /// - public bool IsPagingEnabled { get; private set; } - - /// - public bool IsSplitQuery { get; private set; } - - /// - public bool IsNoTracking { get; private set; } = true; - - /// - /// Initializes a new instance of the class. - /// - protected Specification() - { - } - - /// - /// Initializes a new instance of the class with criteria. - /// - /// The criteria expression. - protected Specification(Expression> criteria) - { - Criteria = criteria; - } - - /// - /// Adds a criteria expression. - /// - /// The criteria expression. - protected void AddCriteria(Expression> criteria) - { - Criteria = criteria; - } - - /// - /// Adds an include expression for eager loading. - /// - /// The include expression. - protected void AddInclude(Expression> includeExpression) - { - Includes.Add(includeExpression); - } - - /// - /// Adds a string-based include for eager loading. - /// - /// The navigation property path. - protected void AddInclude(string includeString) - { - IncludeStrings.Add(includeString); - } - - /// - /// Applies ascending ordering. - /// - /// The order by expression. - protected void ApplyOrderBy(Expression> orderByExpression) - { - OrderBy = orderByExpression; - } - - /// - /// Applies descending ordering. - /// - /// The order by descending expression. - protected void ApplyOrderByDescending(Expression> orderByDescendingExpression) - { - OrderByDescending = orderByDescendingExpression; - } - - /// - /// Applies paging. - /// - /// The number of items to skip. - /// The number of items to take. - protected void ApplyPaging(int skip, int take) - { - Skip = skip; - Take = take; - IsPagingEnabled = true; - } - - /// - /// Enables split queries for better performance with large includes. - /// - protected void EnableSplitQuery() - { - IsSplitQuery = true; - } - - /// - /// Enables change tracking. - /// - protected void EnableTracking() - { - IsNoTracking = false; - } -} diff --git a/src/Mistruna.Core.Contracts/Base/Infrastructure/IUnitOfWork.cs b/src/Mistruna.Core.Contracts/Base/Infrastructure/IUnitOfWork.cs deleted file mode 100644 index d03ebc3..0000000 --- a/src/Mistruna.Core.Contracts/Base/Infrastructure/IUnitOfWork.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Infrastructure; - -/// -/// Represents a unit of work for managing database transactions. -/// -/// -/// The Unit of Work pattern maintains a list of objects affected by a business transaction -/// and coordinates the writing out of changes. -/// -/// -/// -/// await using var transaction = await unitOfWork.BeginTransactionAsync(); -/// try -/// { -/// await userRepository.AddAsync(user); -/// await orderRepository.AddAsync(order); -/// await unitOfWork.SaveChangesAsync(); -/// await transaction.CommitAsync(); -/// } -/// catch -/// { -/// await transaction.RollbackAsync(); -/// throw; -/// } -/// -/// -public interface IUnitOfWork : IDisposable, IAsyncDisposable -{ - /// - /// Saves all changes made in this unit of work to the database. - /// - /// A cancellation token. - /// The number of entities written to the database. - Task SaveChangesAsync(CancellationToken cancellationToken = default); - - /// - /// Begins a new database transaction. - /// - /// A cancellation token. - /// A transaction object that must be committed or rolled back. - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - - /// - /// Executes a block of code within a transaction. - /// - /// The action to execute. - /// A cancellation token. - Task ExecuteInTransactionAsync( - Func action, - CancellationToken cancellationToken = default); - - /// - /// Executes a block of code within a transaction and returns a result. - /// - /// The type of the result. - /// The action to execute. - /// A cancellation token. - /// The result of the action. - Task ExecuteInTransactionAsync( - Func> action, - CancellationToken cancellationToken = default); -} - -/// -/// Represents a transaction owned by a unit of work. -/// -public interface IUnitOfWorkTransaction : IDisposable, IAsyncDisposable -{ - /// - /// Gets the transaction identifier. - /// - Guid TransactionId { get; } - - /// - /// Commits the transaction. - /// - /// A cancellation token. - Task CommitAsync(CancellationToken cancellationToken = default); - - /// - /// Rolls back the transaction. - /// - /// A cancellation token. - Task RollbackAsync(CancellationToken cancellationToken = default); -} - -/// -/// Compatibility alias for . -/// -public interface IDbTransaction : IUnitOfWorkTransaction; \ No newline at end of file diff --git a/src/Mistruna.Core.Contracts/Base/Responses/AuthResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/AuthResponse.cs deleted file mode 100644 index af81e99..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/AuthResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Standard authentication response returned after login, token refresh, or registration. -/// -public class AuthResponse -{ - /// The authenticated user's username. - public string Username { get; set; } = string.Empty; - - /// The authenticated user's email address. - public string Email { get; set; } = string.Empty; - - /// JWT access token for subsequent API calls. - public string Token { get; set; } = string.Empty; - - /// Refresh token used to obtain a new access token without re-authenticating. - public string RefreshToken { get; set; } = string.Empty; -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/DeleteResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/DeleteResponse.cs deleted file mode 100644 index 745986e..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/DeleteResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Contracts.Base.Responses; - -/// Standard response of deletion operation -public class DeleteResponse : IResponse -{ - /// Return Message - public string? Message { get; set; } - - /// Id of deleted object - public Guid Id { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/ErrorResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/ErrorResponse.cs deleted file mode 100644 index 71a41ac..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/ErrorResponse.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Represents a standardized error response. -/// -public class ErrorResponse -{ - /// Gets or sets the error message. - public string Message { get; set; } = string.Empty; - - /// Gets or sets the error code. - public string? ErrorCode { get; set; } - - /// Gets or sets additional error details. - public object? Details { get; set; } - - /// Gets or sets the trace identifier for debugging. - public string? TraceId { get; set; } - - /// Gets or sets the timestamp when the error occurred. - public DateTime Timestamp { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/ExistResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/ExistResponse.cs deleted file mode 100644 index 70f61e5..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/ExistResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Contracts.Base.Responses; - -/// Standard response of exist operation -public class ExistResponse : IResponse -{ - /// Return Message - public string? Message { get; set; } - - /// Condition - public bool Exist { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/ItemResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/ItemResponse.cs deleted file mode 100644 index 572568f..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/ItemResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Standard response for single item operations (Get/Create/Update). -/// -/// Type of the returned item. -public class ItemResponse : IResponse where T : class -{ - /// Operation message (success, warning, error). - public string? Message { get; set; } - - /// Returned item (can be null). - public T? Item { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/PageView.cs b/src/Mistruna.Core.Contracts/Base/Responses/PageView.cs deleted file mode 100644 index 2e8f53b..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/PageView.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Represents a paginated view of items with navigation metadata. -/// -/// The type of items in the page. -public class PageView where TModel : class -{ - /// Current page number (1-based). - public int Page { get; set; } - - /// Number of items per page. - public int Count { get; set; } - - /// Total number of items across all pages. - public int Total { get; set; } - - /// List of items for the current page. - public IList Elements { get; set; } = new List(); - - /// Gets the total number of pages. - public int TotalPages => Count > 0 ? (int)Math.Ceiling((double)Total / Count) : 0; - - /// Gets a value indicating whether there is a previous page. - public bool HasPreviousPage => Page > 1; - - /// Gets a value indicating whether there is a next page. - public bool HasNextPage => Page < TotalPages; - - /// Gets the first item number on the current page (1-based). - public int FirstItemOnPage => Total > 0 ? (Page - 1) * Count + 1 : 0; - - /// Gets the last item number on the current page (1-based). - public int LastItemOnPage => Total > 0 ? Math.Min(Page * Count, Total) : 0; - - /// - /// Creates an empty PageView. - /// - public static PageView Empty(int page = 1, int count = 10) => new() - { - Page = page, - Count = count, - Total = 0, - Elements = new List() - }; - - /// - /// Creates a PageView from a collection with pagination parameters. - /// - /// The items for the current page. - /// The total count of all items. - /// The current page number. - /// The page size. - public static PageView Create( - IList items, - int totalCount, - int page, - int pageSize) => new() - { - Page = page, - Count = pageSize, - Total = totalCount, - Elements = items - }; -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/PageViewResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/PageViewResponse.cs deleted file mode 100644 index 9eca82a..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/PageViewResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Standard paginated response including metadata and message. -/// -/// Type of elements in the page. -public class PageViewResponse : PageView, IResponse where T : class -{ - /// Optional message attached to the response. - public string? Message { get; set; } -} diff --git a/src/Mistruna.Core.Contracts/Base/Responses/ValidationErrorResponse.cs b/src/Mistruna.Core.Contracts/Base/Responses/ValidationErrorResponse.cs deleted file mode 100644 index 7bce4f5..0000000 --- a/src/Mistruna.Core.Contracts/Base/Responses/ValidationErrorResponse.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Responses; - -/// -/// Represents a validation error response returned in case of a 400 Bad Request status with validation errors. -/// -public class ValidationErrorResponse -{ - /// Gets or sets the type URI identifying the problem. - public string Type { get; set; } = string.Empty; - - /// Gets or sets the short, human-readable summary of the problem. - public string Title { get; set; } = string.Empty; - - /// Gets or sets the HTTP status code. - public int Status { get; set; } - - /// Gets or sets the trace identifier for debugging. - public string TraceId { get; set; } = string.Empty; - - /// Gets or sets the validation errors keyed by field name. - public Dictionary Errors { get; set; } = new(); -} diff --git a/src/Mistruna.Core.Contracts/Base/Results/Error.cs b/src/Mistruna.Core.Contracts/Base/Results/Error.cs deleted file mode 100644 index 222164d..0000000 --- a/src/Mistruna.Core.Contracts/Base/Results/Error.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Results; - -/// -/// Represents an error with a code and description. -/// -/// -/// Use this record to create strongly-typed errors that can be used with the Result pattern. -/// -/// -/// -/// public static class UserErrors -/// { -/// public static readonly Error NotFound = new("User.NotFound", "User was not found"); -/// public static readonly Error EmailInUse = new("User.EmailInUse", "Email is already in use"); -/// } -/// -/// -public sealed record Error -{ - /// - /// Represents no error (success state). - /// - public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None); - - /// - /// Represents a null value error. - /// - public static readonly Error NullValue = new("Error.NullValue", "A null value was provided", ErrorType.Validation); - - /// - /// Initializes a new instance of the record. - /// - /// The unique error code. - /// A human-readable description of the error. - /// The type of error. - public Error(string code, string description, ErrorType type = ErrorType.Failure) - { - Code = code; - Description = description; - Type = type; - } - - /// - /// Gets the unique error code. - /// - public string Code { get; } - - /// - /// Gets the human-readable description of the error. - /// - public string Description { get; } - - /// - /// Gets the type of error. - /// - public ErrorType Type { get; } - - /// - /// Creates a failure error. - /// - public static Error Failure(string code, string description) => - new(code, description, ErrorType.Failure); - - /// - /// Creates a validation error. - /// - public static Error Validation(string code, string description) => - new(code, description, ErrorType.Validation); - - /// - /// Creates a not found error. - /// - public static Error NotFound(string code, string description) => - new(code, description, ErrorType.NotFound); - - /// - /// Creates a conflict error. - /// - public static Error Conflict(string code, string description) => - new(code, description, ErrorType.Conflict); - - /// - /// Creates an unauthorized error. - /// - public static Error Unauthorized(string code, string description) => - new(code, description, ErrorType.Unauthorized); - - /// - /// Creates a forbidden error. - /// - public static Error Forbidden(string code, string description) => - new(code, description, ErrorType.Forbidden); - - /// - /// Implicitly converts an error to a string (returns the code). - /// - public static implicit operator string(Error error) => error.Code; - - /// - /// Returns the error code as string representation. - /// - public override string ToString() => Code; -} - -/// -/// Represents the type of error. -/// -public enum ErrorType -{ - /// No error. - None = 0, - - /// General failure. - Failure = 1, - - /// Validation error. - Validation = 2, - - /// Resource not found. - NotFound = 3, - - /// Resource conflict. - Conflict = 4, - - /// Unauthorized access. - Unauthorized = 5, - - /// Forbidden access. - Forbidden = 6 -} diff --git a/src/Mistruna.Core.Contracts/Base/Results/Result.cs b/src/Mistruna.Core.Contracts/Base/Results/Result.cs deleted file mode 100644 index ddf4f37..0000000 --- a/src/Mistruna.Core.Contracts/Base/Results/Result.cs +++ /dev/null @@ -1,264 +0,0 @@ -namespace Mistruna.Core.Contracts.Base.Results; - -/// -/// Represents the result of an operation that can either succeed or fail. -/// -/// -/// Use this type instead of throwing exceptions for expected failure cases. -/// This provides a more functional approach to error handling. -/// -/// -/// -/// public Result<User> GetUserById(Guid id) -/// { -/// var user = _repository.GetById(id); -/// if (user is null) -/// return Result.Failure<User>(UserErrors.NotFound); -/// return Result.Success(user); -/// } -/// -/// -public class Result -{ - /// - /// Initializes a new instance of the class. - /// - /// Indicates whether the operation was successful. - /// The error if the operation failed. - /// - /// Thrown when trying to create an invalid result state. - /// - protected Result(bool isSuccess, Error error) - { - if (isSuccess && error != Error.None) - throw new InvalidOperationException("Success result cannot have an error."); - if (!isSuccess && error == Error.None) - throw new InvalidOperationException("Failure result must have an error."); - - IsSuccess = isSuccess; - Error = error; - } - - /// - /// Initializes a new instance of the class with multiple errors. - /// - protected Result(bool isSuccess, Error[] errors) - { - IsSuccess = isSuccess; - Errors = errors; - Error = errors.FirstOrDefault() ?? Error.None; - } - - /// - /// Gets a value indicating whether the operation was successful. - /// - public bool IsSuccess { get; } - - /// - /// Gets a value indicating whether the operation failed. - /// - public bool IsFailure => !IsSuccess; - - /// - /// Gets the error if the operation failed. - /// - public Error Error { get; } - - /// - /// Gets all errors if multiple validation errors occurred. - /// - public Error[] Errors { get; } = Array.Empty(); - - /// - /// Creates a successful result. - /// - public static Result Success() => new(true, Error.None); - - /// - /// Creates a successful result with a value. - /// - public static Result Success(TValue value) => new(value, true, Error.None); - - /// - /// Creates a failure result with the specified error. - /// - public static Result Failure(Error error) => new(false, error); - - /// - /// Creates a failure result with the specified error. - /// - public static Result Failure(Error error) => new(default, false, error); - - /// - /// Creates a failure result with multiple errors (typically for validation). - /// - public static Result Failure(Error[] errors) => new(default, false, errors); - - /// - /// Creates a result based on a condition. - /// - public static Result Create(bool condition, Error error) => - condition ? Success() : Failure(error); - - /// - /// Creates a result based on a value, using NullValue error if null. - /// - public static Result Create(TValue? value) => - value is not null ? Success(value) : Failure(Error.NullValue); - - /// - /// Combines multiple results into a single result. - /// - public static Result Combine(params Result[] results) - { - var failures = results.Where(r => r.IsFailure).ToArray(); - return failures.Length != 0 - ? new Result(false, failures.Select(f => f.Error).ToArray()) - : Success(); - } -} - -/// -/// Represents the result of an operation that returns a value. -/// -/// The type of the value returned on success. -public class Result : Result -{ - private readonly TValue? _value; - - /// - /// Initializes a new instance of the class. - /// - internal Result(TValue? value, bool isSuccess, Error error) - : base(isSuccess, error) - { - _value = value; - } - - /// - /// Initializes a new instance of the class with multiple errors. - /// - internal Result(TValue? value, bool isSuccess, Error[] errors) - : base(isSuccess, errors) - { - _value = value; - } - - /// - /// Gets the value if the operation was successful. - /// - /// Thrown when accessing value of a failed result. - public TValue Value => IsSuccess - ? _value! - : throw new InvalidOperationException("Cannot access value of a failed result."); - - /// - /// Gets the value or the default if the operation failed. - /// - public TValue? ValueOrDefault => _value; - - /// - /// Implicitly converts a value to a successful result. - /// - public static implicit operator Result(TValue? value) => - value is not null ? Success(value) : Failure(Error.NullValue); - - /// - /// Implicitly converts an error to a failed result. - /// - public static implicit operator Result(Error error) => Failure(error); - - /// - /// Maps the value to a new type if successful. - /// - public Result Map(Func mapper) => - IsSuccess ? Success(mapper(_value!)) : Failure(Error); - - /// - /// Binds to another result-returning function if successful. - /// - public Result Bind(Func> binder) => - IsSuccess ? binder(_value!) : Failure(Error); - - /// - /// Matches on the result, executing the appropriate function. - /// - public TResult Match(Func onSuccess, Func onFailure) => - IsSuccess ? onSuccess(_value!) : onFailure(Error); - - /// - /// Executes an action if successful. - /// - public Result Tap(Action action) - { - if (IsSuccess) action(_value!); - return this; - } - - /// - /// Returns an alternative value if the result is a failure. - /// - public TValue GetValueOrDefault(TValue defaultValue) => - IsSuccess ? _value! : defaultValue; - - /// - /// Returns an alternative value from a factory if the result is a failure. - /// - public TValue GetValueOrDefault(Func factory) => - IsSuccess ? _value! : factory(); -} - -/// -/// Extension methods for Result types. -/// -public static class ResultExtensions -{ - /// - /// Converts a nullable value to a Result. - /// - public static Result ToResult(this T? value, Error error) where T : class => - value is not null ? Result.Success(value) : Result.Failure(error); - - /// - /// Converts a nullable struct to a Result. - /// - public static Result ToResult(this T? value, Error error) where T : struct => - value.HasValue ? Result.Success(value.Value) : Result.Failure(error); - - /// - /// Ensures a condition is met, otherwise returns a failure. - /// - public static Result Ensure(this Result result, Func predicate, Error error) => - result.IsFailure ? result : predicate(result.Value) ? result : Result.Failure(error); - - /// - /// Combines two results into a tuple. - /// - public static Result<(T1, T2)> Combine(this Result first, Result second) - { - if (first.IsFailure) return Result.Failure<(T1, T2)>(first.Error); - if (second.IsFailure) return Result.Failure<(T1, T2)>(second.Error); - return Result.Success((first.Value, second.Value)); - } - - /// - /// Transforms a collection of results into a result of a collection. - /// - public static Result> Combine(this IEnumerable> results) - { - var list = new List(); - var errors = new List(); - - foreach (var result in results) - { - if (result.IsSuccess) - list.Add(result.Value); - else - errors.Add(result.Error); - } - - return errors.Count != 0 - ? new Result>(default, false, errors.ToArray()) - : Result.Success>(list); - } -} diff --git a/src/Mistruna.Core.Contracts/Errors/IValidationErrorProvider.cs b/src/Mistruna.Core.Contracts/Errors/IValidationErrorProvider.cs deleted file mode 100644 index be55b6f..0000000 --- a/src/Mistruna.Core.Contracts/Errors/IValidationErrorProvider.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation.Results; - -namespace Mistruna.Core.Contracts.Errors; - -/// -/// Provides methods to create validation error messages for common scenarios. -/// -public interface IValidationErrorProvider -{ - /// - /// Returns a validation error indicating that an entity was not found. - /// - IEnumerable NotFound(); - - /// - /// Returns a validation error indicating that an entity was not found by its Id. - /// - /// The Id of the entity. - IEnumerable NotFound(Guid id); -} diff --git a/src/Mistruna.Core.Contracts/Helpers/EnumDescriptionHelper.cs b/src/Mistruna.Core.Contracts/Helpers/EnumDescriptionHelper.cs deleted file mode 100644 index 1ce4452..0000000 --- a/src/Mistruna.Core.Contracts/Helpers/EnumDescriptionHelper.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel; - -namespace Mistruna.Core.Contracts.Helpers; - -/// -/// Provides helper methods for retrieving descriptive text from enum values -/// decorated with . -/// -public static class EnumDescriptionHelper -{ - /// - /// Asynchronously retrieves the description strings for a collection of enum values. - /// - /// The enum values to process. - /// An array of description strings (falls back to when no attribute is present). - public static async Task GetEnumDescriptionsAsync(IEnumerable enumValues) - => await Task.WhenAll(enumValues.Select(GetEnumDescriptionAsync).ToArray()); - - private static async Task GetEnumDescriptionAsync(Enum value) - { - return await Task.Run(() => - { - var field = value.GetType().GetField(value.ToString()); - if (field is null) - { - return value.ToString(); - } - - var attribute = (DescriptionAttribute)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)); - return attribute is null - ? value.ToString() - : attribute.Description; - }); - } -} diff --git a/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IBus.cs b/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IBus.cs deleted file mode 100644 index 2e51e53..0000000 --- a/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IBus.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; - -/// -/// Defines a message bus interface for asynchronous message processing. -/// -public interface IBus -{ - /// - /// Executes the message bus processing loop asynchronously. - /// - /// A cancellation token to signal when to stop processing. - /// A task representing the asynchronous operation. - Task ExecuteAsync(CancellationToken stoppingToken); -} diff --git a/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IRabbitManager.cs b/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IRabbitManager.cs deleted file mode 100644 index b2b135d..0000000 --- a/src/Mistruna.Core.Contracts/Microservices/RabbitMq/Services/Interfaces/IRabbitManager.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; - -/// -/// Manages RabbitMQ message operations including publishing, subscribing, and sending messages. -/// -public interface IRabbitManager -{ - /// - /// Publishes a message to a RabbitMQ exchange. - /// - /// The type of message to publish. - /// The message to publish. - /// The name of the exchange. - /// The type of exchange (e.g., "direct", "topic", "fanout"). - /// The routing key for the message. - void Publish(T message, string exchangeName, string exchangeType, string routeKey) where T : class; - - /// - /// Subscribes to a RabbitMQ queue and retrieves a message of the specified entity type. - /// - /// The entity type to subscribe for. - /// The name of the queue to subscribe to. - /// The identifier of the entity to retrieve. - /// The retrieved entity. - T Subscribe(string queueName, Guid id) where T : IEntity; - - /// - /// Sends a message directly to the default exchange. - /// - /// The type of message to send. - /// The message to send. - void Send(T message) where T : class; -} diff --git a/src/Mistruna.Core.Contracts/Mistruna.Core.Contracts.csproj b/src/Mistruna.Core.Contracts/Mistruna.Core.Contracts.csproj deleted file mode 100644 index d1a0fc4..0000000 --- a/src/Mistruna.Core.Contracts/Mistruna.Core.Contracts.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - netstandard2.0 - latest - enable - $(NoWarn);CS8777;CS1591 - true - true - Mistruna.Core.Contracts - Interfaces and contracts for Mistruna.Core SDK. Use this package when you only need the interfaces without the full implementation. - - - - - - - - - - - - - - - - <_Parameter1>Mistruna.Core.Tests - - - - diff --git a/src/Mistruna.Core.Contracts/Models/CurrencyInfo.cs b/src/Mistruna.Core.Contracts/Models/CurrencyInfo.cs deleted file mode 100644 index 2ebf635..0000000 --- a/src/Mistruna.Core.Contracts/Models/CurrencyInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Mistruna.Core.Contracts.Models; - -/// -/// Structured metadata for a currency: code, numeric ISO code, full name, and display symbol. -/// -/// ISO 4217 alphabetic currency code (e.g., "USD"). -/// ISO 4217 numeric currency code. -/// Human-readable full currency name (e.g., "US Dollar"). -/// Display symbol (e.g., "$", "€"). -public record struct CurrencyInfo( - string Code, - short Numeric, - string FullName, - string Symbol); diff --git a/src/Mistruna.Core/Base/Persistence/EfGenericRepository.cs b/src/Mistruna.Core.EfCore/EfGenericRepository.cs similarity index 69% rename from src/Mistruna.Core/Base/Persistence/EfGenericRepository.cs rename to src/Mistruna.Core.EfCore/EfGenericRepository.cs index aa35c54..6bb14b5 100644 --- a/src/Mistruna.Core/Base/Persistence/EfGenericRepository.cs +++ b/src/Mistruna.Core.EfCore/EfGenericRepository.cs @@ -1,34 +1,22 @@ using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Base.Persistence; - -/// -/// EF Core implementation of . -/// Provides full CRUD, specification-based queries, and paging out of the box. -/// -/// The entity type. -/// The DbContext type. -public abstract class EfGenericRepository(TContext context, DbSet dbSet) - : IGenericRepository +using Mistruna.Core.Abstractions.Persistence; + +namespace Mistruna.Core.EfCore; + +/// EF Core implementation of the generic repository contract. +public class EfGenericRepository(DbContext context) : IGenericRepository where T : class, IEntity - where TContext : DbContext { - protected TContext Context { get; } = context; - protected DbSet DbSet { get; } = dbSet; + private DbSet DbSet { get; } = context.Set(); /// public async ValueTask AddAsync(T entity, CancellationToken cancellationToken = default) - { - await DbSet.AddAsync(entity, cancellationToken); - } + => await DbSet.AddAsync(entity, cancellationToken); /// public async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) - { - await DbSet.AddRangeAsync(entities, cancellationToken); - } + => await DbSet.AddRangeAsync(entities, cancellationToken); /// public async ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default) @@ -48,28 +36,25 @@ public async Task> FindAsync( public async Task> FindAsync( ISpecification specification, CancellationToken cancellationToken = default) - { - var query = SpecificationEvaluator.GetQuery(DbSet.AsQueryable(), specification); - return await query.ToListAsync(cancellationToken); - } + => await SpecificationEvaluator + .GetQuery(DbSet.AsQueryable(), specification) + .ToListAsync(cancellationToken); /// public async Task FindOneAsync( ISpecification specification, CancellationToken cancellationToken = default) - { - var query = SpecificationEvaluator.GetQuery(DbSet.AsQueryable(), specification); - return await query.FirstOrDefaultAsync(cancellationToken); - } + => await SpecificationEvaluator + .GetQuery(DbSet.AsQueryable(), specification) + .FirstOrDefaultAsync(cancellationToken); /// public async Task CountAsync( ISpecification specification, CancellationToken cancellationToken = default) - { - var query = SpecificationEvaluator.GetQuery(DbSet.AsQueryable(), specification); - return await query.CountAsync(cancellationToken); - } + => await SpecificationEvaluator + .GetQuery(DbSet.AsQueryable(), specification) + .CountAsync(cancellationToken); /// public async Task CountAsync( diff --git a/src/Mistruna.Core/Base/Persistence/EfUnitOfWork.cs b/src/Mistruna.Core.EfCore/EfUnitOfWork.cs similarity index 88% rename from src/Mistruna.Core/Base/Persistence/EfUnitOfWork.cs rename to src/Mistruna.Core.EfCore/EfUnitOfWork.cs index 18c9012..80b9e4e 100644 --- a/src/Mistruna.Core/Base/Persistence/EfUnitOfWork.cs +++ b/src/Mistruna.Core.EfCore/EfUnitOfWork.cs @@ -1,14 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using Mistruna.Core.Contracts.Base.Infrastructure; +using Mistruna.Core.Abstractions.Persistence; -namespace Mistruna.Core.Base.Persistence; +namespace Mistruna.Core.EfCore; -/// -/// EF Core implementation of . -/// Wraps to provide transactional boundaries. -/// -/// The DbContext type. +/// EF Core implementation of a transactional unit of work. public sealed class EfUnitOfWork(TContext context) : IUnitOfWork where TContext : DbContext { @@ -23,13 +19,14 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de } /// - public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + public async Task BeginTransactionAsync( + CancellationToken cancellationToken = default) { ThrowIfDisposed(); - if (_currentTransaction is not null) { - throw new InvalidOperationException("A database transaction is already active for this unit of work."); + throw new InvalidOperationException( + "A database transaction is already active for this unit of work."); } _currentTransaction = await context.Database.BeginTransactionAsync(cancellationToken); @@ -96,7 +93,6 @@ public void Dispose() } _currentTransaction?.Dispose(); - context.Dispose(); _disposed = true; GC.SuppressFinalize(this); } @@ -114,7 +110,6 @@ public async ValueTask DisposeAsync() await _currentTransaction.DisposeAsync(); } - await context.DisposeAsync(); _disposed = true; GC.SuppressFinalize(this); } @@ -136,12 +131,9 @@ private void ThrowIfDisposed() } } -/// -/// Wraps EF Core's to implement . -/// internal sealed class EfDbTransaction( IDbContextTransaction transaction, - Action onCompleted) : IUnitOfWorkTransaction, IDbTransaction + Action onCompleted) : IDbTransaction { private bool _completed; private bool _disposed; diff --git a/src/Mistruna.Core.EfCore/Mistruna.Core.EfCore.csproj b/src/Mistruna.Core.EfCore/Mistruna.Core.EfCore.csproj new file mode 100644 index 0000000..a8609a5 --- /dev/null +++ b/src/Mistruna.Core.EfCore/Mistruna.Core.EfCore.csproj @@ -0,0 +1,22 @@ + + + net10.0 + true + Mistruna.Core.EfCore + EF Core generic repository, unit of work, and persistence helpers for Mistruna. + Mistruna.Core.EfCore + + + + + + + + + + + + + + + diff --git a/src/Mistruna.Core.EfCore/ServiceCollectionExtensions.cs b/src/Mistruna.Core.EfCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..32e4170 --- /dev/null +++ b/src/Mistruna.Core.EfCore/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Abstractions.Persistence; + +namespace Mistruna.Core.EfCore; + +/// Registers Mistruna EF Core persistence services. +public static class ServiceCollectionExtensions +{ + /// Registers the generic repository and unit of work for a DbContext. + public static IServiceCollection AddMistrunaEfCore(this IServiceCollection services) + where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped( + serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped>(); + services.AddScoped(typeof(IGenericRepository<>), typeof(EfGenericRepository<>)); + return services; + } +} diff --git a/src/Mistruna.Core.EfCore/SpecificationEvaluator.cs b/src/Mistruna.Core.EfCore/SpecificationEvaluator.cs new file mode 100644 index 0000000..534f5ac --- /dev/null +++ b/src/Mistruna.Core.EfCore/SpecificationEvaluator.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Mistruna.Core.Abstractions.Persistence; + +namespace Mistruna.Core.EfCore; + +internal static class SpecificationEvaluator +{ + public static IQueryable GetQuery( + IQueryable query, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(specification); + + if (specification.Criteria is not null) + { + query = query.Where(specification.Criteria); + } + + query = specification.Includes.Aggregate( + query, + static (current, include) => current.Include(include)); + + query = specification.IncludeStrings.Aggregate( + query, + static (current, include) => current.Include(include)); + + if (specification.OrderBy is not null) + { + query = query.OrderBy(specification.OrderBy); + } + else if (specification.OrderByDescending is not null) + { + query = query.OrderByDescending(specification.OrderByDescending); + } + + if (specification.IsPagingEnabled) + { + query = query.Skip(specification.Skip ?? 0); + if (specification.Take is not null) + { + query = query.Take(specification.Take.Value); + } + } + + if (specification.IsSplitQuery) + { + query = query.AsSplitQuery(); + } + + return specification.IsNoTracking ? query.AsNoTracking() : query; + } +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mistruna.Core.Messaging.RabbitMq/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2ca1411 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Mistruna.Core.Messaging.RabbitMq.Internal; +using RabbitMQ.Client; + +namespace Mistruna.Core.Messaging.RabbitMq.DependencyInjection; + +/// +/// Registers Mistruna RabbitMQ publishing and consumers. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the RabbitMQ bus using the Mistruna:RabbitMq configuration section. + /// The legacy RabbitMq section is also accepted. + /// + /// The service collection. + /// The application configuration. + /// The service collection. + public static IServiceCollection AddMistrunaRabbitMq( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var section = configuration.GetSection("Mistruna:RabbitMq"); + if (!section.Exists()) + { + section = configuration.GetSection("RabbitMq"); + } + + services.AddOptions().Bind(section); + services.TryAddSingleton(); + services.TryAddSingleton( + provider => new RabbitBus(provider.GetRequiredService())); + return services; + } + + /// + /// Registers a typed asynchronous RabbitMQ consumer. + /// + /// The message type. + /// The scoped handler type. + /// The service collection. + /// The queue to declare and consume. + /// The exchange to declare and bind. + /// The queue binding routing key. + /// The RabbitMQ exchange type. + /// Whether the exchange and queue survive broker restarts. + /// The service collection. + public static IServiceCollection AddMistrunaRabbitConsumer( + this IServiceCollection services, + string queue, + string exchange, + string routingKey, + string exchangeType = ExchangeType.Topic, + bool durable = true) + where TMessage : class + where THandler : class, IMistrunaRabbitMessageHandler + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(queue); + ArgumentException.ThrowIfNullOrWhiteSpace(exchange); + ArgumentException.ThrowIfNullOrWhiteSpace(routingKey); + ArgumentException.ThrowIfNullOrWhiteSpace(exchangeType); + + services.AddScoped(); + services.AddScoped>( + provider => provider.GetRequiredService()); + services.AddSingleton( + new RabbitConsumerRegistration( + queue, + exchange, + routingKey, + exchangeType, + durable)); + services.AddHostedService>(); + return services; + } +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitBus.cs b/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitBus.cs new file mode 100644 index 0000000..2a8443d --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitBus.cs @@ -0,0 +1,23 @@ +namespace Mistruna.Core.Messaging.RabbitMq; + +/// +/// Publishes messages through RabbitMQ. +/// +public interface IMistrunaRabbitBus +{ + /// + /// Publishes a message to an exchange using the specified routing key. + /// + /// The message type. + /// The message to publish. + /// The destination exchange. + /// The routing key. + /// A token that cancels the operation. + /// A task representing the publish operation. + Task PublishAsync( + T message, + string exchange, + string routingKey, + CancellationToken cancellationToken = default) + where T : class; +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitMessageHandler.cs b/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitMessageHandler.cs new file mode 100644 index 0000000..628faf5 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/IMistrunaRabbitMessageHandler.cs @@ -0,0 +1,17 @@ +namespace Mistruna.Core.Messaging.RabbitMq; + +/// +/// Handles a message delivered by a registered RabbitMQ consumer. +/// +/// The message type. +public interface IMistrunaRabbitMessageHandler + where TMessage : class +{ + /// + /// Handles a delivered message. + /// + /// The deserialized message. + /// A token that cancels message processing. + /// A task representing the handler operation. + Task HandleAsync(TMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/IRabbitPublisherChannel.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/IRabbitPublisherChannel.cs new file mode 100644 index 0000000..d9d0888 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/IRabbitPublisherChannel.cs @@ -0,0 +1,13 @@ +using RabbitMQ.Client; + +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal interface IRabbitPublisherChannel +{ + Task PublishAsync( + string exchange, + string routingKey, + BasicProperties properties, + ReadOnlyMemory body, + CancellationToken cancellationToken); +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConnectionFactory.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConnectionFactory.cs new file mode 100644 index 0000000..0eb0128 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConnectionFactory.cs @@ -0,0 +1,17 @@ +using RabbitMQ.Client; + +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal static class RabbitConnectionFactory +{ + internal static ConnectionFactory Create(RabbitMqOptions options) + => new() + { + HostName = options.HostName, + Port = options.Port, + UserName = options.UserName, + Password = options.Password, + VirtualHost = options.VirtualHost, + ClientProvidedName = options.ClientProvidedName + }; +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerHostedService.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerHostedService.cs new file mode 100644 index 0000000..e2e69a3 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerHostedService.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal sealed class RabbitConsumerHostedService( + IOptions options, + IServiceScopeFactory scopeFactory, + RabbitConsumerRegistration registration, + ILogger> logger) + : BackgroundService + where TMessage : class + where THandler : class, IMistrunaRabbitMessageHandler +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var factory = RabbitConnectionFactory.Create(options.Value); + await using var connection = await factory + .CreateConnectionAsync(stoppingToken) + .ConfigureAwait(false); + await using var channel = await connection + .CreateChannelAsync(cancellationToken: stoppingToken) + .ConfigureAwait(false); + + await channel.ExchangeDeclareAsync( + registration.Exchange, + registration.ExchangeType, + registration.Durable, + autoDelete: false, + cancellationToken: stoppingToken).ConfigureAwait(false); + await channel.QueueDeclareAsync( + registration.Queue, + registration.Durable, + exclusive: false, + autoDelete: false, + cancellationToken: stoppingToken).ConfigureAwait(false); + await channel.QueueBindAsync( + registration.Queue, + registration.Exchange, + registration.RoutingKey, + cancellationToken: stoppingToken).ConfigureAwait(false); + await channel.BasicQosAsync( + prefetchSize: 0, + prefetchCount: 1, + global: false, + cancellationToken: stoppingToken).ConfigureAwait(false); + + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, delivery) => + { + try + { + var message = RabbitMessageSerializer.Deserialize(delivery.Body); + await using var scope = scopeFactory.CreateAsyncScope(); + var handler = scope.ServiceProvider + .GetRequiredService>(); + await handler.HandleAsync(message, stoppingToken).ConfigureAwait(false); + await channel.BasicAckAsync( + delivery.DeliveryTag, + multiple: false, + stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The host is stopping; the unacknowledged delivery will be requeued by RabbitMQ. + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to handle RabbitMQ message from queue {Queue} with routing key {RoutingKey}.", + registration.Queue, + registration.RoutingKey); + await channel.BasicNackAsync( + delivery.DeliveryTag, + multiple: false, + requeue: false, + CancellationToken.None).ConfigureAwait(false); + } + }; + + await channel.BasicConsumeAsync( + registration.Queue, + autoAck: false, + consumer, + stoppingToken).ConfigureAwait(false); + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken).ConfigureAwait(false); + } +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerRegistration.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerRegistration.cs new file mode 100644 index 0000000..8a045cb --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitConsumerRegistration.cs @@ -0,0 +1,10 @@ +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal sealed record RabbitConsumerRegistration( + string Queue, + string Exchange, + string RoutingKey, + string ExchangeType, + bool Durable) + where TMessage : class + where THandler : class, IMistrunaRabbitMessageHandler; diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitMessageSerializer.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitMessageSerializer.cs new file mode 100644 index 0000000..3e30b64 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitMessageSerializer.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal static class RabbitMessageSerializer +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + internal static ReadOnlyMemory Serialize(T message) + where T : class + => JsonSerializer.SerializeToUtf8Bytes(message, Options); + + internal static T Deserialize(ReadOnlyMemory body) + where T : class + => JsonSerializer.Deserialize(body.Span, Options) + ?? throw new JsonException($"RabbitMQ message body did not contain a {typeof(T).Name}."); +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitPublisherChannel.cs b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitPublisherChannel.cs new file mode 100644 index 0000000..415036d --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Internal/RabbitPublisherChannel.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Options; +using RabbitMQ.Client; + +namespace Mistruna.Core.Messaging.RabbitMq.Internal; + +internal sealed class RabbitPublisherChannel(IOptions options) + : IRabbitPublisherChannel, IAsyncDisposable +{ + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private IConnection? _connection; + + public async Task PublishAsync( + string exchange, + string routingKey, + BasicProperties properties, + ReadOnlyMemory body, + CancellationToken cancellationToken) + { + var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var channel = await connection + .CreateChannelAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + await channel + .BasicPublishAsync(exchange, routingKey, false, properties, body, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_connection is not null) + { + await _connection.DisposeAsync().ConfigureAwait(false); + } + + _connectionLock.Dispose(); + } + + private async Task GetConnectionAsync(CancellationToken cancellationToken) + { + if (_connection is { IsOpen: true }) + { + return _connection; + } + + await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_connection is { IsOpen: true }) + { + return _connection; + } + + if (_connection is not null) + { + await _connection.DisposeAsync().ConfigureAwait(false); + } + + _connection = await RabbitConnectionFactory + .Create(options.Value) + .CreateConnectionAsync(cancellationToken) + .ConfigureAwait(false); + return _connection; + } + finally + { + _connectionLock.Release(); + } + } +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/Mistruna.Core.Messaging.RabbitMq.csproj b/src/Mistruna.Core.Messaging.RabbitMq/Mistruna.Core.Messaging.RabbitMq.csproj new file mode 100644 index 0000000..a6e61d3 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/Mistruna.Core.Messaging.RabbitMq.csproj @@ -0,0 +1,26 @@ + + + net10.0 + true + Mistruna.Core.Messaging.RabbitMq + Async RabbitMQ.Client 7 message bus for Mistruna. + Mistruna.Core.Messaging.RabbitMq + + + + + + + + + + + <_Parameter1>Mistruna.Core.Tests + + + + + + + + diff --git a/src/Mistruna.Core.Messaging.RabbitMq/RabbitBus.cs b/src/Mistruna.Core.Messaging.RabbitMq/RabbitBus.cs new file mode 100644 index 0000000..00449e4 --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/RabbitBus.cs @@ -0,0 +1,45 @@ +using Mistruna.Core.Messaging.RabbitMq.Internal; +using RabbitMQ.Client; + +namespace Mistruna.Core.Messaging.RabbitMq; + +/// +/// Publishes persistent JSON messages with RabbitMQ.Client 7. +/// +public sealed class RabbitBus : IMistrunaRabbitBus +{ + private readonly IRabbitPublisherChannel _publisher; + + internal RabbitBus(IRabbitPublisherChannel publisher) + { + _publisher = publisher; + } + + /// + public Task PublishAsync( + T message, + string exchange, + string routingKey, + CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(message); + ArgumentException.ThrowIfNullOrWhiteSpace(exchange); + ArgumentException.ThrowIfNullOrWhiteSpace(routingKey); + + var properties = new BasicProperties + { + ContentEncoding = "utf-8", + ContentType = "application/json", + Persistent = true, + Type = typeof(T).Name + }; + + return _publisher.PublishAsync( + exchange, + routingKey, + properties, + RabbitMessageSerializer.Serialize(message), + cancellationToken); + } +} diff --git a/src/Mistruna.Core.Messaging.RabbitMq/RabbitMqOptions.cs b/src/Mistruna.Core.Messaging.RabbitMq/RabbitMqOptions.cs new file mode 100644 index 0000000..e375dca --- /dev/null +++ b/src/Mistruna.Core.Messaging.RabbitMq/RabbitMqOptions.cs @@ -0,0 +1,25 @@ +namespace Mistruna.Core.Messaging.RabbitMq; + +/// +/// Configures the RabbitMQ broker connection. +/// +public sealed class RabbitMqOptions +{ + /// Gets or sets the broker host name. + public string HostName { get; set; } = "localhost"; + + /// Gets or sets the broker port. + public int Port { get; set; } = 5672; + + /// Gets or sets the broker user name. + public string UserName { get; set; } = "guest"; + + /// Gets or sets the broker password. + public string Password { get; set; } = "guest"; + + /// Gets or sets the virtual host. + public string VirtualHost { get; set; } = "/"; + + /// Gets or sets the client-provided connection name. + public string ClientProvidedName { get; set; } = "Mistruna"; +} diff --git a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs similarity index 88% rename from src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs index 460f215..88678bf 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationHandler.cs @@ -4,9 +4,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authorization.Plans; -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; /// /// ASP.NET Core authentication handler for the @@ -30,7 +30,8 @@ public ApiKeyAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { var key = ExtractKey(); - if (key is null) return AuthenticateResult.NoResult(); + if (key is null) + return AuthenticateResult.NoResult(); var validator = Context.RequestServices.GetRequiredService(); var result = await validator.ValidateAsync(key, Context.RequestAborted); @@ -57,14 +58,16 @@ protected override async Task HandleAuthenticateAsync() if (Context.Request.Headers.TryGetValue(Options.HeaderName, out var headerValue)) { var headerKey = headerValue.ToString(); - if (!string.IsNullOrWhiteSpace(headerKey)) return headerKey; + if (!string.IsNullOrWhiteSpace(headerKey)) + return headerKey; } if (Options.AllowQueryParameter && Context.Request.Query.TryGetValue(Options.QueryParameterName, out var queryValue)) { var queryKey = queryValue.ToString(); - if (!string.IsNullOrWhiteSpace(queryKey)) return queryKey; + if (!string.IsNullOrWhiteSpace(queryKey)) + return queryKey; } return null; diff --git a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs similarity index 92% rename from src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs index 6a911af..6303fcc 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyAuthenticationOptions.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authentication; -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { diff --git a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyClaimTypes.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyClaimTypes.cs similarity index 88% rename from src/Mistruna.Core/Authentication/ApiKey/ApiKeyClaimTypes.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyClaimTypes.cs index 323d326..a9d20b1 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyClaimTypes.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyClaimTypes.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; /// /// Claim type names set by on the authenticated principal. diff --git a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyDefaults.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyDefaults.cs similarity index 86% rename from src/Mistruna.Core/Authentication/ApiKey/ApiKeyDefaults.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyDefaults.cs index b66be68..ec5fee4 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyDefaults.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyDefaults.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; /// /// Default constants for the API key authentication scheme. diff --git a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyValidationResult.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyValidationResult.cs similarity index 88% rename from src/Mistruna.Core/Authentication/ApiKey/ApiKeyValidationResult.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyValidationResult.cs index e84df93..cc30fde 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/ApiKeyValidationResult.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/ApiKeyValidationResult.cs @@ -1,6 +1,6 @@ -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authorization.Plans; -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; /// /// Outcome of an check. Use the static factories rather than diff --git a/src/Mistruna.Core/Authentication/ApiKey/IApiKeyValidator.cs b/src/Mistruna.Core.Monetization/Authentication/ApiKey/IApiKeyValidator.cs similarity index 87% rename from src/Mistruna.Core/Authentication/ApiKey/IApiKeyValidator.cs rename to src/Mistruna.Core.Monetization/Authentication/ApiKey/IApiKeyValidator.cs index ab1688a..c1a7179 100644 --- a/src/Mistruna.Core/Authentication/ApiKey/IApiKeyValidator.cs +++ b/src/Mistruna.Core.Monetization/Authentication/ApiKey/IApiKeyValidator.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Authentication.ApiKey; +namespace Mistruna.Core.Monetization.Authentication.ApiKey; /// /// Resolves an inbound API-key string to an . diff --git a/src/Mistruna.Core/Authorization/Plans/Plan.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/Plan.cs similarity index 84% rename from src/Mistruna.Core/Authorization/Plans/Plan.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/Plan.cs index 76e6463..2f0012f 100644 --- a/src/Mistruna.Core/Authorization/Plans/Plan.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/Plan.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Subscription tiers in ascending order of capability. diff --git a/src/Mistruna.Core/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs similarity index 97% rename from src/Mistruna.Core/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs index 604c1fd..80c4cba 100644 --- a/src/Mistruna.Core/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/PlanAuthorizationMiddlewareResultHandler.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http; -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Translates a failed into an HTTP 402 diff --git a/src/Mistruna.Core/Authorization/Plans/PlanClaimTypes.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/PlanClaimTypes.cs similarity index 90% rename from src/Mistruna.Core/Authorization/Plans/PlanClaimTypes.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/PlanClaimTypes.cs index 4084ce5..64d2750 100644 --- a/src/Mistruna.Core/Authorization/Plans/PlanClaimTypes.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/PlanClaimTypes.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Claim type names used to surface subscription tier and quota on the current principal. diff --git a/src/Mistruna.Core/Authorization/Plans/RequiresPlanAttribute.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAttribute.cs similarity index 94% rename from src/Mistruna.Core/Authorization/Plans/RequiresPlanAttribute.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAttribute.cs index b6f9e42..c5a64bb 100644 --- a/src/Mistruna.Core/Authorization/Plans/RequiresPlanAttribute.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAttribute.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Declares that an MVC controller or action requires the caller's subscription tier diff --git a/src/Mistruna.Core/Authorization/Plans/RequiresPlanAuthorizationHandler.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAuthorizationHandler.cs similarity index 94% rename from src/Mistruna.Core/Authorization/Plans/RequiresPlanAuthorizationHandler.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAuthorizationHandler.cs index 764dc37..4b8c191 100644 --- a/src/Mistruna.Core/Authorization/Plans/RequiresPlanAuthorizationHandler.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanAuthorizationHandler.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Evaluates by reading the diff --git a/src/Mistruna.Core/Authorization/Plans/RequiresPlanPolicyProvider.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanPolicyProvider.cs similarity index 96% rename from src/Mistruna.Core/Authorization/Plans/RequiresPlanPolicyProvider.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanPolicyProvider.cs index e99233f..d46397a 100644 --- a/src/Mistruna.Core/Authorization/Plans/RequiresPlanPolicyProvider.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanPolicyProvider.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Dynamic policy provider that synthesizes an from a diff --git a/src/Mistruna.Core/Authorization/Plans/RequiresPlanRequirement.cs b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanRequirement.cs similarity index 85% rename from src/Mistruna.Core/Authorization/Plans/RequiresPlanRequirement.cs rename to src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanRequirement.cs index d54cf82..1161c2c 100644 --- a/src/Mistruna.Core/Authorization/Plans/RequiresPlanRequirement.cs +++ b/src/Mistruna.Core.Monetization/Authorization/Plans/RequiresPlanRequirement.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace Mistruna.Core.Authorization.Plans; +namespace Mistruna.Core.Monetization.Authorization.Plans; /// /// Requirement that the current principal's claim diff --git a/src/Mistruna.Core/Extensions/MonetizationApplicationBuilderExtensions.cs b/src/Mistruna.Core.Monetization/DependencyInjection/ApplicationBuilderExtensions.cs similarity index 65% rename from src/Mistruna.Core/Extensions/MonetizationApplicationBuilderExtensions.cs rename to src/Mistruna.Core.Monetization/DependencyInjection/ApplicationBuilderExtensions.cs index 2b80562..9e7adde 100644 --- a/src/Mistruna.Core/Extensions/MonetizationApplicationBuilderExtensions.cs +++ b/src/Mistruna.Core.Monetization/DependencyInjection/ApplicationBuilderExtensions.cs @@ -2,11 +2,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Mistruna.Core.Idempotency; -using Mistruna.Core.Metering; -using Mistruna.Core.RateLimiting.Tiered; +using Mistruna.Core.Monetization.Idempotency; +using Mistruna.Core.Monetization.Metering; +using Mistruna.Core.Monetization.RateLimiting.Tiered; -namespace Mistruna.Core.Extensions; +namespace Mistruna.Core.Monetization.DependencyInjection; /// /// Application builder extensions for registering monetization middlewares @@ -14,42 +14,65 @@ namespace Mistruna.Core.Extensions; /// public static class MonetizationApplicationBuilderExtensions { + /// Adds all Mistruna monetization middleware to the pipeline. + public static IApplicationBuilder UseMistrunaMonetization(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app + .UseMistrunaTieredRateLimiting() + .UseMistrunaIdempotency() + .UseMistrunaUsageMetering(); + } + /// /// Adds to the pipeline. Must run AFTER /// UseAuthentication() so the principal's tier/quota claims are available. /// - public static IApplicationBuilder UseTieredRateLimiting(this IApplicationBuilder app) => - app.Use((context, next) => + public static IApplicationBuilder UseMistrunaTieredRateLimiting(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use((context, next) => { var options = context.RequestServices.GetRequiredService(); var store = context.RequestServices.GetRequiredService(); var logger = context.RequestServices.GetRequiredService>(); return new TieredRateLimitMiddleware(_ => next(context), options, store, logger).InvokeAsync(context); }); + } /// /// Adds to the pipeline. Intercepts mutating requests /// carrying an Idempotency-Key header and caches responses for replay. /// - public static IApplicationBuilder UseIdempotency(this IApplicationBuilder app) => - app.Use((context, next) => + public static IApplicationBuilder UseMistrunaIdempotency(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use((context, next) => { var options = context.RequestServices.GetRequiredService(); var store = context.RequestServices.GetRequiredService(); var logger = context.RequestServices.GetRequiredService>(); return new IdempotencyMiddleware(_ => next(context), options, store, logger).InvokeAsync(context); }); + } /// /// Adds to the pipeline. Records successful API key usage /// for billing and quota tracking. /// - public static IApplicationBuilder UseUsageMetering(this IApplicationBuilder app) => - app.Use((context, next) => + public static IApplicationBuilder UseMistrunaUsageMetering(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use((context, next) => { var options = context.RequestServices.GetRequiredService(); var meter = context.RequestServices.GetRequiredService(); var logger = context.RequestServices.GetRequiredService>(); return new UsageMeteringMiddleware(_ => next(context), options, meter, logger).InvokeAsync(context); }); + } } diff --git a/src/Mistruna.Core/Extensions/MonetizationServiceCollectionExtensions.cs b/src/Mistruna.Core.Monetization/DependencyInjection/ServiceCollectionExtensions.cs similarity index 58% rename from src/Mistruna.Core/Extensions/MonetizationServiceCollectionExtensions.cs rename to src/Mistruna.Core.Monetization/DependencyInjection/ServiceCollectionExtensions.cs index 1cf5874..7b1eb6f 100644 --- a/src/Mistruna.Core/Extensions/MonetizationServiceCollectionExtensions.cs +++ b/src/Mistruna.Core.Monetization/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Authorization.Plans; -using Mistruna.Core.Idempotency; -using Mistruna.Core.Metering; -using Mistruna.Core.RateLimiting.Tiered; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authorization.Plans; +using Mistruna.Core.Monetization.Idempotency; +using Mistruna.Core.Monetization.Metering; +using Mistruna.Core.Monetization.RateLimiting.Tiered; -namespace Mistruna.Core.Extensions; +namespace Mistruna.Core.Monetization.DependencyInjection; /// /// Registration helpers for the Mistruna.Core monetization primitives. @@ -17,17 +17,48 @@ namespace Mistruna.Core.Extensions; /// public static class MonetizationServiceCollectionExtensions { + /// Registers the complete Mistruna monetization stack. + public static IServiceCollection AddMistrunaMonetization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddMistrunaApiKeyAuthentication(); + services.AddMistrunaPlanAuthorization(); + services.AddMistrunaTieredRateLimiting(); + services.AddMistrunaIdempotency(); + services.AddMistrunaUsageMetering(); + + return services; + } + /// /// Registers the ApiKey authentication scheme. The consumer must also register an /// implementation (typically a Redis-cached delegate to the /// user-service token-exchange endpoint). /// + public static IServiceCollection AddMistrunaApiKeyAuthentication( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddAuthentication().AddMistrunaApiKeyAuthentication(configure); + return services; + } + + /// + /// Adds the Mistruna API-key scheme to an existing authentication builder. + /// public static AuthenticationBuilder AddMistrunaApiKeyAuthentication( this AuthenticationBuilder builder, - Action? configure = null) => - builder.AddScheme( + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.AddScheme( ApiKeyDefaults.AuthenticationScheme, configure ?? (_ => { })); + } /// /// Registers , the dynamic @@ -38,6 +69,9 @@ public static AuthenticationBuilder AddMistrunaApiKeyAuthentication( /// public static IServiceCollection AddMistrunaPlanAuthorization(this IServiceCollection services) { + ArgumentNullException.ThrowIfNull(services); + + services.AddAuthorization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -45,14 +79,17 @@ public static IServiceCollection AddMistrunaPlanAuthorization(this IServiceColle } /// - /// Registers (defaults to if - /// IConnectionMultiplexer is available — otherwise ) - /// and the singleton. + /// Registers and uses TryAdd to register + /// as . The Redis implementation + /// requires an IConnectionMultiplexer; register a custom + /// before calling this method to override it. /// public static IServiceCollection AddMistrunaTieredRateLimiting( this IServiceCollection services, Action? configure = null) { + ArgumentNullException.ThrowIfNull(services); + var options = new TieredRateLimitOptions(); configure?.Invoke(options); services.AddSingleton(options); @@ -64,6 +101,8 @@ public static IServiceCollection AddMistrunaIdempotency( this IServiceCollection services, Action? configure = null) { + ArgumentNullException.ThrowIfNull(services); + var options = new IdempotencyOptions(); configure?.Invoke(options); services.AddSingleton(options); @@ -75,6 +114,8 @@ public static IServiceCollection AddMistrunaUsageMetering( this IServiceCollection services, Action? configure = null) { + ArgumentNullException.ThrowIfNull(services); + var options = new UsageMeteringOptions(); configure?.Invoke(options); services.AddSingleton(options); diff --git a/src/Mistruna.Core/Idempotency/IIdempotencyStore.cs b/src/Mistruna.Core.Monetization/Idempotency/IIdempotencyStore.cs similarity index 89% rename from src/Mistruna.Core/Idempotency/IIdempotencyStore.cs rename to src/Mistruna.Core.Monetization/Idempotency/IIdempotencyStore.cs index 7c471fe..958b7a6 100644 --- a/src/Mistruna.Core/Idempotency/IIdempotencyStore.cs +++ b/src/Mistruna.Core.Monetization/Idempotency/IIdempotencyStore.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Idempotency; +namespace Mistruna.Core.Monetization.Idempotency; /// /// Persists and retrieves snapshots keyed by the combination diff --git a/src/Mistruna.Core/Idempotency/IdempotencyMiddleware.cs b/src/Mistruna.Core.Monetization/Idempotency/IdempotencyMiddleware.cs similarity index 91% rename from src/Mistruna.Core/Idempotency/IdempotencyMiddleware.cs rename to src/Mistruna.Core.Monetization/Idempotency/IdempotencyMiddleware.cs index 67edab9..33f87ae 100644 --- a/src/Mistruna.Core/Idempotency/IdempotencyMiddleware.cs +++ b/src/Mistruna.Core.Monetization/Idempotency/IdempotencyMiddleware.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Mistruna.Core.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authentication.ApiKey; -namespace Mistruna.Core.Idempotency; +namespace Mistruna.Core.Monetization.Idempotency; /// /// Intercepts mutating requests that carry an Idempotency-Key header. On a first call, @@ -40,7 +40,8 @@ public async Task InvokeAsync(HttpContext context) { logger.LogDebug("Idempotent replay for key {Key}", storeKey); context.Response.StatusCode = cached.StatusCode; - if (cached.ContentType is not null) context.Response.ContentType = cached.ContentType; + if (cached.ContentType is not null) + context.Response.ContentType = cached.ContentType; await context.Response.Body.WriteAsync(cached.Body, context.RequestAborted); return; } diff --git a/src/Mistruna.Core/Idempotency/IdempotencyOptions.cs b/src/Mistruna.Core.Monetization/Idempotency/IdempotencyOptions.cs similarity index 94% rename from src/Mistruna.Core/Idempotency/IdempotencyOptions.cs rename to src/Mistruna.Core.Monetization/Idempotency/IdempotencyOptions.cs index 647f6ee..04132a6 100644 --- a/src/Mistruna.Core/Idempotency/IdempotencyOptions.cs +++ b/src/Mistruna.Core.Monetization/Idempotency/IdempotencyOptions.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Idempotency; +namespace Mistruna.Core.Monetization.Idempotency; /// /// Configuration options for the idempotency middleware. diff --git a/src/Mistruna.Core/Idempotency/IdempotentResponse.cs b/src/Mistruna.Core.Monetization/Idempotency/IdempotentResponse.cs similarity index 89% rename from src/Mistruna.Core/Idempotency/IdempotentResponse.cs rename to src/Mistruna.Core.Monetization/Idempotency/IdempotentResponse.cs index eec6430..f0ecfa7 100644 --- a/src/Mistruna.Core/Idempotency/IdempotentResponse.cs +++ b/src/Mistruna.Core.Monetization/Idempotency/IdempotentResponse.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Idempotency; +namespace Mistruna.Core.Monetization.Idempotency; /// /// Serialized snapshot of an HTTP response used for idempotent replay. diff --git a/src/Mistruna.Core/Idempotency/RedisIdempotencyStore.cs b/src/Mistruna.Core.Monetization/Idempotency/RedisIdempotencyStore.cs similarity index 92% rename from src/Mistruna.Core/Idempotency/RedisIdempotencyStore.cs rename to src/Mistruna.Core.Monetization/Idempotency/RedisIdempotencyStore.cs index 5181d0f..e14a299 100644 --- a/src/Mistruna.Core/Idempotency/RedisIdempotencyStore.cs +++ b/src/Mistruna.Core.Monetization/Idempotency/RedisIdempotencyStore.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using StackExchange.Redis; -namespace Mistruna.Core.Idempotency; +namespace Mistruna.Core.Monetization.Idempotency; /// /// Redis-backed store. Serializes as JSON and stores under @@ -22,7 +22,8 @@ public sealed class RedisIdempotencyStore( try { var value = await redis.GetDatabase().StringGetAsync(key); - if (!value.HasValue) return null; + if (!value.HasValue) + return null; return JsonSerializer.Deserialize((string)value!, Json); } catch (RedisException ex) diff --git a/src/Mistruna.Core/Metering/IUsageMeter.cs b/src/Mistruna.Core.Monetization/Metering/IUsageMeter.cs similarity index 90% rename from src/Mistruna.Core/Metering/IUsageMeter.cs rename to src/Mistruna.Core.Monetization/Metering/IUsageMeter.cs index 3a785c4..415f59a 100644 --- a/src/Mistruna.Core/Metering/IUsageMeter.cs +++ b/src/Mistruna.Core.Monetization/Metering/IUsageMeter.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Metering; +namespace Mistruna.Core.Monetization.Metering; /// /// Records successful API-key usage events. Called fire-and-forget from the diff --git a/src/Mistruna.Core/Metering/RedisUsageMeter.cs b/src/Mistruna.Core.Monetization/Metering/RedisUsageMeter.cs similarity index 94% rename from src/Mistruna.Core/Metering/RedisUsageMeter.cs rename to src/Mistruna.Core.Monetization/Metering/RedisUsageMeter.cs index e607720..c2dcf1c 100644 --- a/src/Mistruna.Core/Metering/RedisUsageMeter.cs +++ b/src/Mistruna.Core.Monetization/Metering/RedisUsageMeter.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; using StackExchange.Redis; -namespace Mistruna.Core.Metering; +namespace Mistruna.Core.Monetization.Metering; /// /// Redis-backed meter. Counter key format: usage:{apiKeyId:N}:{yyyyMMdd}. diff --git a/src/Mistruna.Core/Metering/UsageMeteringMiddleware.cs b/src/Mistruna.Core.Monetization/Metering/UsageMeteringMiddleware.cs similarity index 92% rename from src/Mistruna.Core/Metering/UsageMeteringMiddleware.cs rename to src/Mistruna.Core.Monetization/Metering/UsageMeteringMiddleware.cs index 4f91934..b4b8fd9 100644 --- a/src/Mistruna.Core/Metering/UsageMeteringMiddleware.cs +++ b/src/Mistruna.Core.Monetization/Metering/UsageMeteringMiddleware.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Mistruna.Core.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authentication.ApiKey; -namespace Mistruna.Core.Metering; +namespace Mistruna.Core.Monetization.Metering; /// /// Records a usage event for the current API key after the pipeline produces a successful diff --git a/src/Mistruna.Core/Metering/UsageMeteringOptions.cs b/src/Mistruna.Core.Monetization/Metering/UsageMeteringOptions.cs similarity index 89% rename from src/Mistruna.Core/Metering/UsageMeteringOptions.cs rename to src/Mistruna.Core.Monetization/Metering/UsageMeteringOptions.cs index 2622cb2..35c7efa 100644 --- a/src/Mistruna.Core/Metering/UsageMeteringOptions.cs +++ b/src/Mistruna.Core.Monetization/Metering/UsageMeteringOptions.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Metering; +namespace Mistruna.Core.Monetization.Metering; /// /// Configuration options for usage metering middleware. diff --git a/src/Mistruna.Core.Monetization/Mistruna.Core.Monetization.csproj b/src/Mistruna.Core.Monetization/Mistruna.Core.Monetization.csproj new file mode 100644 index 0000000..d36d8db --- /dev/null +++ b/src/Mistruna.Core.Monetization/Mistruna.Core.Monetization.csproj @@ -0,0 +1,19 @@ + + + net10.0 + true + Mistruna.Core.Monetization + API key auth, plan authorization, tiered rate limits, idempotency, and usage metering for Mistruna. + Mistruna.Core.Monetization + $(NoWarn);CS1591 + + + + + + + + + + + \ No newline at end of file diff --git a/src/Mistruna.Core/RateLimiting/Tiered/IQuotaStore.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/IQuotaStore.cs similarity index 92% rename from src/Mistruna.Core/RateLimiting/Tiered/IQuotaStore.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/IQuotaStore.cs index 4de81f0..4adc512 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/IQuotaStore.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/IQuotaStore.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// Atomic counter store used by . diff --git a/src/Mistruna.Core/RateLimiting/Tiered/InMemoryQuotaStore.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/InMemoryQuotaStore.cs similarity index 88% rename from src/Mistruna.Core/RateLimiting/Tiered/InMemoryQuotaStore.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/InMemoryQuotaStore.cs index db29800..876fce4 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/InMemoryQuotaStore.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/InMemoryQuotaStore.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// In-process counter store. Used as a fallback when Redis is unavailable and as a @@ -23,7 +23,8 @@ public Task IncrementAsync(string key, TimeSpan window, Cancellatio var elapsed = now - entry.WindowStart; var remaining = entry.Window - elapsed; - if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero; + if (remaining < TimeSpan.Zero) + remaining = TimeSpan.Zero; return Task.FromResult(new QuotaResult(entry.Count, remaining)); } diff --git a/src/Mistruna.Core/RateLimiting/Tiered/QuotaResult.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/QuotaResult.cs similarity index 85% rename from src/Mistruna.Core/RateLimiting/Tiered/QuotaResult.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/QuotaResult.cs index d4d0cef..c27d898 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/QuotaResult.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/QuotaResult.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// Outcome of an call. diff --git a/src/Mistruna.Core/RateLimiting/Tiered/RedisQuotaStore.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/RedisQuotaStore.cs similarity index 94% rename from src/Mistruna.Core/RateLimiting/Tiered/RedisQuotaStore.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/RedisQuotaStore.cs index feb9ea1..c2ef42a 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/RedisQuotaStore.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/RedisQuotaStore.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; using StackExchange.Redis; -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// Redis-backed counter store. Uses INCR with a TTL attached on the first write of the diff --git a/src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitMiddleware.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitMiddleware.cs similarity index 94% rename from src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitMiddleware.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitMiddleware.cs index 3566d68..0b23752 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitMiddleware.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitMiddleware.cs @@ -2,10 +2,10 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authorization.Plans; -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// Middleware that enforces a per-API-key quota for the current calendar day (UTC) by diff --git a/src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitOptions.cs b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitOptions.cs similarity index 91% rename from src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitOptions.cs rename to src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitOptions.cs index 54052c6..c6bb277 100644 --- a/src/Mistruna.Core/RateLimiting/Tiered/TieredRateLimitOptions.cs +++ b/src/Mistruna.Core.Monetization/RateLimiting/Tiered/TieredRateLimitOptions.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.RateLimiting.Tiered; +namespace Mistruna.Core.Monetization.RateLimiting.Tiered; /// /// Configuration options for tiered rate limiting middleware. diff --git a/src/Mistruna.Core.Observability/Behaviors/OpenTelemetryMediatorBehavior.cs b/src/Mistruna.Core.Observability/Behaviors/OpenTelemetryMediatorBehavior.cs new file mode 100644 index 0000000..771e56a --- /dev/null +++ b/src/Mistruna.Core.Observability/Behaviors/OpenTelemetryMediatorBehavior.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using MediatR; +using Mistruna.Core.Abstractions.Cqrs; +using Mistruna.Core.Abstractions.Results; + +namespace Mistruna.Core.Observability.Behaviors; + +/// Creates OpenTelemetry activities for MediatR pipeline requests. +public sealed class OpenTelemetryMediatorBehavior : IPipelineBehavior + where TRequest : notnull +{ + + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestType = typeof(TRequest); + var requestKind = ResolveRequestKind(requestType); + + using var activity = MistrunaMediatorTelemetry.ActivitySource.StartActivity(requestType.Name); + activity?.SetTag("request.type", requestType.FullName ?? requestType.Name); + activity?.SetTag("request.kind", requestKind); + + try + { + var response = await next(cancellationToken); + var outcome = ResolveOutcome(response); + activity?.SetTag("outcome", outcome); + + if (outcome is "failure" or "error") + { + activity?.SetStatus(ActivityStatusCode.Error); + } + + return response; + } + catch (Exception exception) + { + activity?.SetTag("outcome", "error"); + activity?.SetStatus(ActivityStatusCode.Error, exception.Message); + throw; + } + } + + private static string ResolveRequestKind(Type requestType) + { + if (RequestKind.IsCommand(requestType)) + return "command"; + + if (RequestKind.IsQuery(requestType)) + return "query"; + + return "unknown"; + } + + private static string ResolveOutcome(TResponse response) + { + if (response is Result result) + return result.IsSuccess ? "success" : "failure"; + + var responseType = response?.GetType(); + if (responseType?.IsGenericType == true && + responseType.GetGenericTypeDefinition() == typeof(Result<>)) + { + var isSuccess = (bool)responseType.GetProperty(nameof(Result.IsSuccess))!.GetValue(response)!; + return isSuccess ? "success" : "failure"; + } + + return "success"; + } +} diff --git a/src/Mistruna.Core.Observability/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mistruna.Core.Observability/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0dab323 --- /dev/null +++ b/src/Mistruna.Core.Observability/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Observability.Behaviors; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Mistruna.Core.Observability.DependencyInjection; + +/// Registers Mistruna OpenTelemetry tracing, metrics, and MediatR instrumentation. +public static class ServiceCollectionExtensions +{ + /// + /// Adds OpenTelemetry tracing and metrics with ASP.NET Core and HTTP client instrumentation, + /// registers for MediatR, + /// and enables OTLP export when OpenTelemetry:Otlp:Endpoint is configured. + /// + public static IServiceCollection AddMistrunaObservability( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var openTelemetry = services.AddOpenTelemetry() + .ConfigureResource(resource => + { + var serviceName = configuration["OpenTelemetry:ServiceName"] + ?? configuration["Mistruna:Observability:ServiceName"]; + + if (!string.IsNullOrWhiteSpace(serviceName)) + { + resource.AddService(serviceName); + } + }) + .WithTracing(tracing => tracing + .AddSource(MistrunaMediatorTelemetry.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()); + + var otlpEndpoint = configuration["OpenTelemetry:Otlp:Endpoint"]; + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + openTelemetry.UseOtlpExporter( + OtlpExportProtocol.Grpc, + new Uri(otlpEndpoint, UriKind.Absolute)); + } + + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(OpenTelemetryMediatorBehavior<,>)); + + return services; + } +} diff --git a/src/Mistruna.Core.Observability/Mistruna.Core.Observability.csproj b/src/Mistruna.Core.Observability/Mistruna.Core.Observability.csproj new file mode 100644 index 0000000..27af0c5 --- /dev/null +++ b/src/Mistruna.Core.Observability/Mistruna.Core.Observability.csproj @@ -0,0 +1,23 @@ + + + net10.0 + true + Mistruna.Core.Observability + OpenTelemetry setup hooks and MediatR Activity enrichment for Mistruna. + Mistruna.Core.Observability + + + + + + + + + + + + + + + + diff --git a/src/Mistruna.Core.Observability/MistrunaMediatorTelemetry.cs b/src/Mistruna.Core.Observability/MistrunaMediatorTelemetry.cs new file mode 100644 index 0000000..856d608 --- /dev/null +++ b/src/Mistruna.Core.Observability/MistrunaMediatorTelemetry.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Mistruna.Core.Observability; + +/// Shared OpenTelemetry identifiers for MediatR instrumentation. +public static class MistrunaMediatorTelemetry +{ + /// The ActivitySource name used for MediatR request tracing. + public const string ActivitySourceName = "mistruna.mediator"; + + internal static readonly ActivitySource ActivitySource = new(ActivitySourceName); +} diff --git a/src/Mistruna.Core.Resilience/Behaviors/ResilientCommandBehavior.cs b/src/Mistruna.Core.Resilience/Behaviors/ResilientCommandBehavior.cs new file mode 100644 index 0000000..30462ff --- /dev/null +++ b/src/Mistruna.Core.Resilience/Behaviors/ResilientCommandBehavior.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using MediatR; +using Polly; +using Polly.Registry; + +namespace Mistruna.Core.Resilience.Behaviors; + +/// Retries MediatR requests only when marked with . +public sealed class ResilientCommandBehavior( + ResiliencePipelineProvider pipelineProvider) : IPipelineBehavior + where TRequest : notnull +{ + /// + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!IsResilient(typeof(TRequest))) + return next(cancellationToken); + + var pipeline = pipelineProvider.GetPipeline(MistrunaResiliencePresets.MediatorCommand); + return pipeline.ExecuteAsync( + static async (state, cancellationToken) => await state(cancellationToken), + next, + cancellationToken).AsTask(); + } + + private static bool IsResilient(Type requestType) => + requestType.GetCustomAttribute() is not null; +} diff --git a/src/Mistruna.Core.Resilience/DependencyInjection/HttpClientBuilderExtensions.cs b/src/Mistruna.Core.Resilience/DependencyInjection/HttpClientBuilderExtensions.cs new file mode 100644 index 0000000..b262e09 --- /dev/null +++ b/src/Mistruna.Core.Resilience/DependencyInjection/HttpClientBuilderExtensions.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; + +namespace Mistruna.Core.Resilience.DependencyInjection; + +/// HttpClient builder extensions for Mistruna resilience presets. +public static class HttpClientBuilderExtensions +{ + /// + /// Adds a Mistruna resilience handler to the HttpClient using the named preset + /// ( by default). + /// + public static IHttpClientBuilder AddMistrunaResilienceHandler( + this IHttpClientBuilder builder, + string preset = MistrunaResiliencePresets.Standard) + { + ArgumentNullException.ThrowIfNull(builder); + + if (string.Equals(preset, MistrunaResiliencePresets.Disable, StringComparison.OrdinalIgnoreCase)) + return builder; + + if (!MistrunaResiliencePresets.IsKnown(preset)) + { + throw new ArgumentException( + $"Unknown resilience preset '{preset}'. Expected one of: {MistrunaResiliencePresets.Standard}, {MistrunaResiliencePresets.Aggressive}, {MistrunaResiliencePresets.Disable}.", + nameof(preset)); + } + + if (string.Equals(preset, MistrunaResiliencePresets.Aggressive, StringComparison.OrdinalIgnoreCase)) + { + builder.AddStandardResilienceHandler(static options => + { + options.Retry.MaxRetryAttempts = 5; + options.Retry.Delay = TimeSpan.FromMilliseconds(200); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30); + }); + + return builder; + } + + builder.AddStandardResilienceHandler(); + return builder; + } +} diff --git a/src/Mistruna.Core.Resilience/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mistruna.Core.Resilience/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c0c6cc8 --- /dev/null +++ b/src/Mistruna.Core.Resilience/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Resilience.Behaviors; +using Mistruna.Core.Resilience.Internal; +using Polly; +using Polly.Retry; + +namespace Mistruna.Core.Resilience.DependencyInjection; + +/// Registers Mistruna resilience presets and optional marked-command MediatR retry. +public static class ServiceCollectionExtensions +{ + /// + /// Registers named HttpClient resilience presets (, + /// , ) + /// and for MediatR. + /// + public static IServiceCollection AddMistrunaResilience(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddResiliencePipelines(context => + { + context.AddResiliencePipeline( + MistrunaResiliencePresets.Standard, + static (builder, _) => MistrunaHttpResiliencePipelineConfigurator.ConfigureStandard(builder)); + + context.AddResiliencePipeline( + MistrunaResiliencePresets.Aggressive, + static (builder, _) => MistrunaHttpResiliencePipelineConfigurator.ConfigureAggressive(builder)); + + context.AddResiliencePipeline( + MistrunaResiliencePresets.Disable, + static (_, _) => { }); + + context.AddResiliencePipeline( + MistrunaResiliencePresets.MediatorCommand, + static (builder, _) => + { + builder.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + }); + }); + }); + + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ResilientCommandBehavior<,>)); + + return services; + } +} diff --git a/src/Mistruna.Core.Resilience/Internal/MistrunaHttpResiliencePipelineConfigurator.cs b/src/Mistruna.Core.Resilience/Internal/MistrunaHttpResiliencePipelineConfigurator.cs new file mode 100644 index 0000000..c44feed --- /dev/null +++ b/src/Mistruna.Core.Resilience/Internal/MistrunaHttpResiliencePipelineConfigurator.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Http.Resilience; +using Polly; + +namespace Mistruna.Core.Resilience.Internal; + +internal static class MistrunaHttpResiliencePipelineConfigurator +{ + public static void ConfigureStandard(ResiliencePipelineBuilder builder) + => Configure(builder, new HttpStandardResilienceOptions()); + + public static void ConfigureAggressive(ResiliencePipelineBuilder builder) + { + var options = new HttpStandardResilienceOptions(); + options.Retry.MaxRetryAttempts = 5; + options.Retry.Delay = TimeSpan.FromMilliseconds(200); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30); + Configure(builder, options); + } + + private static void Configure( + ResiliencePipelineBuilder builder, + HttpStandardResilienceOptions options) + { + builder + .AddRateLimiter(options.RateLimiter) + .AddTimeout(options.TotalRequestTimeout) + .AddRetry(options.Retry) + .AddCircuitBreaker(options.CircuitBreaker) + .AddTimeout(options.AttemptTimeout); + } +} diff --git a/src/Mistruna.Core.Resilience/Mistruna.Core.Resilience.csproj b/src/Mistruna.Core.Resilience/Mistruna.Core.Resilience.csproj new file mode 100644 index 0000000..a159fa3 --- /dev/null +++ b/src/Mistruna.Core.Resilience/Mistruna.Core.Resilience.csproj @@ -0,0 +1,20 @@ + + + net10.0 + true + Mistruna.Core.Resilience + HttpClient resilience presets and optional marked-command MediatR retry for Mistruna. + Mistruna.Core.Resilience + + + + + + + + + + + + + diff --git a/src/Mistruna.Core.Resilience/MistrunaResiliencePresets.cs b/src/Mistruna.Core.Resilience/MistrunaResiliencePresets.cs new file mode 100644 index 0000000..72cfe10 --- /dev/null +++ b/src/Mistruna.Core.Resilience/MistrunaResiliencePresets.cs @@ -0,0 +1,22 @@ +namespace Mistruna.Core.Resilience; + +/// Named HttpClient resilience preset identifiers. +public static class MistrunaResiliencePresets +{ + /// Default balanced retry, timeout, and circuit breaker settings. + public const string Standard = "Standard"; + + /// More retry attempts with shorter per-attempt timeouts. + public const string Aggressive = "Aggressive"; + + /// Disables HttpClient resilience handlers. + public const string Disable = "Disable"; + + /// MediatR pipeline key for marked command retry. + internal const string MediatorCommand = "MediatorCommand"; + + internal static bool IsKnown(string preset) => + string.Equals(preset, Standard, StringComparison.OrdinalIgnoreCase) || + string.Equals(preset, Aggressive, StringComparison.OrdinalIgnoreCase) || + string.Equals(preset, Disable, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Mistruna.Core.Resilience/ResilientAttribute.cs b/src/Mistruna.Core.Resilience/ResilientAttribute.cs new file mode 100644 index 0000000..c7ad3d3 --- /dev/null +++ b/src/Mistruna.Core.Resilience/ResilientAttribute.cs @@ -0,0 +1,5 @@ +namespace Mistruna.Core.Resilience; + +/// Marks a MediatR request for optional retry via . +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class ResilientAttribute : Attribute; diff --git a/src/Mistruna.Core.Testing/EfCore/AsyncQueryable.cs b/src/Mistruna.Core.Testing/EfCore/AsyncQueryable.cs new file mode 100644 index 0000000..27f57ee --- /dev/null +++ b/src/Mistruna.Core.Testing/EfCore/AsyncQueryable.cs @@ -0,0 +1,40 @@ +using System.Collections; +using System.Linq.Expressions; + +namespace Mistruna.Core.Testing.EfCore; + +/// +/// Creates in-memory queryables backed by for EF Core async tests. +/// +public static class AsyncQueryable +{ + /// + /// Wraps an in-memory sequence as an that supports EF Core async operators. + /// + public static IQueryable From(IEnumerable source) => + new TestAsyncQueryable(source.AsQueryable()); +} + +internal sealed class TestAsyncQueryable : IQueryable, IAsyncEnumerable +{ + private readonly IQueryable _inner; + + public TestAsyncQueryable(IQueryable inner) + { + _inner = inner; + Provider = new TestAsyncQueryProvider(inner.Provider); + } + + public Type ElementType => _inner.ElementType; + + public Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new TestAsyncEnumerator(_inner.GetEnumerator()); +} diff --git a/src/Mistruna.Core/Providers/TestAsyncEnumerable.cs b/src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerable.cs similarity index 92% rename from src/Mistruna.Core/Providers/TestAsyncEnumerable.cs rename to src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerable.cs index b035e10..c30cfe9 100644 --- a/src/Mistruna.Core/Providers/TestAsyncEnumerable.cs +++ b/src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerable.cs @@ -1,6 +1,6 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; -namespace Mistruna.Core.Providers; +namespace Mistruna.Core.Testing.EfCore; /// /// Wraps an in-memory as diff --git a/src/Mistruna.Core/Providers/TestAsyncEnumerator.cs b/src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerator.cs similarity index 93% rename from src/Mistruna.Core/Providers/TestAsyncEnumerator.cs rename to src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerator.cs index 2f9eb84..c6d89ae 100644 --- a/src/Mistruna.Core/Providers/TestAsyncEnumerator.cs +++ b/src/Mistruna.Core.Testing/EfCore/TestAsyncEnumerator.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Providers; +namespace Mistruna.Core.Testing.EfCore; /// /// Adapts a synchronous enumerator to for repository unit tests. diff --git a/src/Mistruna.Core/Providers/TestAsyncQueryProvider.cs b/src/Mistruna.Core.Testing/EfCore/TestAsyncQueryProvider.cs similarity index 95% rename from src/Mistruna.Core/Providers/TestAsyncQueryProvider.cs rename to src/Mistruna.Core.Testing/EfCore/TestAsyncQueryProvider.cs index 54b66ab..284c640 100644 --- a/src/Mistruna.Core/Providers/TestAsyncQueryProvider.cs +++ b/src/Mistruna.Core.Testing/EfCore/TestAsyncQueryProvider.cs @@ -1,7 +1,7 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Query; -namespace Mistruna.Core.Providers; +namespace Mistruna.Core.Testing.EfCore; /// /// In-memory async query provider for unit tests that exercise EF Core repositories diff --git a/src/Mistruna.Core.Testing/Mistruna.Core.Testing.csproj b/src/Mistruna.Core.Testing/Mistruna.Core.Testing.csproj new file mode 100644 index 0000000..09e6850 --- /dev/null +++ b/src/Mistruna.Core.Testing/Mistruna.Core.Testing.csproj @@ -0,0 +1,24 @@ + + + net10.0 + true + Mistruna.Core.Testing + EF async test providers and consumer test helpers for Mistruna. + Mistruna.Core.Testing + + + + + + + + + + + + + + + + + diff --git a/src/Mistruna.Core.Testing/Results/ResultTestExtensions.cs b/src/Mistruna.Core.Testing/Results/ResultTestExtensions.cs new file mode 100644 index 0000000..678c3f3 --- /dev/null +++ b/src/Mistruna.Core.Testing/Results/ResultTestExtensions.cs @@ -0,0 +1,51 @@ +using Mistruna.Core.Abstractions.Results; + +namespace Mistruna.Core.Testing.Results; + +/// +/// Lightweight assertion helpers for and in unit tests. +/// +public static class ResultTestExtensions +{ + /// Asserts the result succeeded. + public static Result ShouldBeSuccess(this Result result) + { + if (result.IsFailure) + throw new InvalidOperationException( + $"Expected success but got failure with code '{result.Error.Code}'."); + + return result; + } + + /// Asserts the result failed with an optional expected error code. + public static Result ShouldBeFailure(this Result result, string? expectedCode = null) + { + if (result.IsSuccess) + throw new InvalidOperationException("Expected failure but result succeeded."); + + if (expectedCode is not null && result.Error.Code != expectedCode) + throw new InvalidOperationException( + $"Expected error code '{expectedCode}' but got '{result.Error.Code}'."); + + return result; + } + + /// Asserts the result succeeded and returns the value. + public static TValue ShouldBeSuccessWithValue(this Result result, TValue? expectedValue = default) + { + result.ShouldBeSuccess(); + + if (expectedValue is not null && !EqualityComparer.Default.Equals(result.Value, expectedValue)) + throw new InvalidOperationException( + $"Expected value '{expectedValue}' but got '{result.Value}'."); + + return result.Value; + } + + /// Asserts the result failed with an optional expected error code. + public static Result ShouldBeFailure(this Result result, string? expectedCode = null) + { + ShouldBeFailure((Result)result, expectedCode); + return result; + } +} diff --git a/src/Mistruna.Core/Abstractions/ICommand.cs b/src/Mistruna.Core/Abstractions/ICommand.cs deleted file mode 100644 index e794800..0000000 --- a/src/Mistruna.Core/Abstractions/ICommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediatR; - -namespace Mistruna.Core.Abstractions; - -/// -/// Marker interface for commands (write operations). -/// Implement on MediatR requests that mutate application state. -/// -/// -/// Use together with to separate reads from writes. -/// Pipeline behaviors can target commands only (for example audit logging). -/// -public interface ICommand : IRequest; - -/// -/// Marker interface for commands that return a response. -/// -/// The response type. -public interface ICommand : IRequest; diff --git a/src/Mistruna.Core/Abstractions/IQuery.cs b/src/Mistruna.Core/Abstractions/IQuery.cs deleted file mode 100644 index 80cf3c6..0000000 --- a/src/Mistruna.Core/Abstractions/IQuery.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MediatR; - -namespace Mistruna.Core.Abstractions; - -/// -/// Marker interface for queries (read-only operations). -/// -/// The response type. -/// -/// Queries should not change application state. Register audit or metering behaviors -/// only for / requests. -/// -public interface IQuery : IRequest; diff --git a/src/Mistruna.Core/Base/Persistence/SpecificationEvaluator.cs b/src/Mistruna.Core/Base/Persistence/SpecificationEvaluator.cs deleted file mode 100644 index 7e75a17..0000000 --- a/src/Mistruna.Core/Base/Persistence/SpecificationEvaluator.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Base.Persistence; - -/// -/// Evaluates against an , -/// applying criteria, includes, ordering, paging, tracking, and split-query options. -/// -public static class SpecificationEvaluator -{ - /// - /// Applies the specification to the given queryable. - /// - public static IQueryable GetQuery(IQueryable inputQuery, ISpecification specification) - where T : class - { - var query = inputQuery; - - if (specification.Criteria is not null) - { - query = query.Where(specification.Criteria); - } - - foreach (var include in specification.Includes) - { - query = query.Include(include); - } - - foreach (var includeString in specification.IncludeStrings) - { - query = query.Include(includeString); - } - - if (specification.OrderBy is not null) - { - query = query.OrderBy(specification.OrderBy); - } - else if (specification.OrderByDescending is not null) - { - query = query.OrderByDescending(specification.OrderByDescending); - } - - if (specification.IsPagingEnabled) - { - if (specification.Skip.HasValue) - { - query = query.Skip(specification.Skip.Value); - } - - if (specification.Take.HasValue) - { - query = query.Take(specification.Take.Value); - } - } - - if (specification.IsSplitQuery) - { - query = query.AsSplitQuery(); - } - - if (specification.IsNoTracking) - { - query = query.AsNoTracking(); - } - - return query; - } -} diff --git a/src/Mistruna.Core/Behaviors/LoggingBehavior.cs b/src/Mistruna.Core/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..1db1cfe --- /dev/null +++ b/src/Mistruna.Core/Behaviors/LoggingBehavior.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Mistruna.Core.Behaviors; + +/// Logs request execution and elapsed time. +public sealed class LoggingBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + private const int SlowRequestThresholdMs = 500; + + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var requestId = Guid.NewGuid().ToString("N")[..8]; + + logger.LogInformation( + "[{RequestId}] Starting request {RequestName}", + requestId, + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(cancellationToken); + stopwatch.Stop(); + + if (stopwatch.ElapsedMilliseconds > SlowRequestThresholdMs) + { + logger.LogWarning( + "[{RequestId}] Slow request {RequestName} completed in {ElapsedMs}ms", + requestId, + requestName, + stopwatch.ElapsedMilliseconds); + } + else + { + logger.LogInformation( + "[{RequestId}] Completed request {RequestName} in {ElapsedMs}ms", + requestId, + requestName, + stopwatch.ElapsedMilliseconds); + } + + return response; + } + catch (Exception exception) + { + stopwatch.Stop(); + logger.LogError( + exception, + "[{RequestId}] Request {RequestName} failed after {ElapsedMs}ms", + requestId, + requestName, + stopwatch.ElapsedMilliseconds); + throw; + } + } +} diff --git a/src/Mistruna.Core/Behaviors/RequestValidationBehavior.cs b/src/Mistruna.Core/Behaviors/RequestValidationBehavior.cs new file mode 100644 index 0000000..3aac16a --- /dev/null +++ b/src/Mistruna.Core/Behaviors/RequestValidationBehavior.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using MediatR; + +namespace Mistruna.Core.Behaviors; + +/// Validates requests before invoking their handlers. +public sealed class RequestValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (!validators.Any()) + return await next(cancellationToken); + + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll( + validators.Select(validator => validator.ValidateAsync(context, cancellationToken))); + var failures = validationResults + .SelectMany(result => result.Errors) + .Where(failure => failure is not null) + .ToList(); + + if (failures.Count > 0) + throw new ValidationException(failures); + + return await next(cancellationToken); + } +} diff --git a/src/Mistruna.Core/Behaviors/ResultValidationBehavior.cs b/src/Mistruna.Core/Behaviors/ResultValidationBehavior.cs new file mode 100644 index 0000000..626c646 --- /dev/null +++ b/src/Mistruna.Core/Behaviors/ResultValidationBehavior.cs @@ -0,0 +1,61 @@ +using FluentValidation; +using MediatR; +using Mistruna.Core.Abstractions.Results; + +namespace Mistruna.Core.Behaviors; + +/// +/// Validates requests and returns a failed result when the response supports it. +/// +public sealed class ResultValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull, IRequest + where TResponse : class +{ + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(cancellationToken); + + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll( + validators.Select(validator => validator.ValidateAsync(context, cancellationToken))); + var failures = validationResults + .SelectMany(result => result.Errors) + .Where(failure => failure is not null) + .ToList(); + + return failures.Count == 0 + ? await next(cancellationToken) + : CreateValidationErrorResult(failures); + } + + private static TResponse CreateValidationErrorResult( + List failures) + { + var responseType = typeof(TResponse); + + if (responseType.IsGenericType && + responseType.GetGenericTypeDefinition() == typeof(Result<>)) + { + var valueType = responseType.GetGenericArguments()[0]; + var error = Error.Validation( + "Validation.Failed", + string.Join( + "; ", + failures.Select(failure => + $"{failure.PropertyName}: {failure.ErrorMessage}"))); + var resultType = typeof(Result<>).MakeGenericType(valueType); + var failureMethod = resultType.GetMethod("Failure", [typeof(Error)]); + + return (TResponse)failureMethod!.Invoke(null, [error])!; + } + + throw new ValidationException(failures); + } +} diff --git a/src/Mistruna.Core/DependencyInjection/MistrunaCoreOptions.cs b/src/Mistruna.Core/DependencyInjection/MistrunaCoreOptions.cs new file mode 100644 index 0000000..ce0454f --- /dev/null +++ b/src/Mistruna.Core/DependencyInjection/MistrunaCoreOptions.cs @@ -0,0 +1,35 @@ +using System.Reflection; + +namespace Mistruna.Core.DependencyInjection; + +/// Configures the Mistruna.Core service registrations. +public sealed class MistrunaCoreOptions +{ + internal List Assemblies { get; } = []; + + internal bool EnableValidation { get; private set; } + + internal bool EnableLoggingBehavior { get; private set; } + + /// Adds assemblies containing MediatR handlers and validators. + public MistrunaCoreOptions RegisterAssemblies(params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(assemblies); + Assemblies.AddRange(assemblies); + return this; + } + + /// Enables FluentValidation validator discovery and request validation. + public MistrunaCoreOptions AddValidation() + { + EnableValidation = true; + return this; + } + + /// Enables request execution logging. + public MistrunaCoreOptions AddLoggingBehavior() + { + EnableLoggingBehavior = true; + return this; + } +} diff --git a/src/Mistruna.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mistruna.Core/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5339698 --- /dev/null +++ b/src/Mistruna.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Behaviors; + +namespace Mistruna.Core.DependencyInjection; + +/// Provides Mistruna.Core dependency injection registrations. +public static class ServiceCollectionExtensions +{ + /// Adds configured Mistruna.Core services. + public static IServiceCollection AddMistrunaCore( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new MistrunaCoreOptions(); + configure?.Invoke(options); + + if (options.EnableValidation && options.Assemblies.Count == 0) + { + throw new InvalidOperationException( + "AddValidation() requires RegisterAssemblies(...) so validators can be discovered."); + } + + if (options.Assemblies.Count > 0) + { + var assemblies = options.Assemblies.Distinct().ToArray(); + services.AddMediatR(configuration => + configuration.RegisterServicesFromAssemblies(assemblies)); + + if (options.EnableValidation) + AddValidators(services, assemblies); + } + + if (options.EnableLoggingBehavior) + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + + if (options.EnableValidation) + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); + + return services; + } + + private static void AddValidators(IServiceCollection services, params System.Reflection.Assembly[] assemblies) + { + var validatorTypes = assemblies + .SelectMany(assembly => assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsGenericTypeDefinition) + .SelectMany(type => type.GetInterfaces() + .Where(@interface => + @interface.IsGenericType && + @interface.GetGenericTypeDefinition() == typeof(IValidator<>)) + .Select(@interface => new + { + Interface = @interface, + Implementation = type + }))); + + foreach (var validator in validatorTypes) + services.AddTransient(validator.Interface, validator.Implementation); + } +} diff --git a/src/Mistruna.Core/Enums/Currency.cs b/src/Mistruna.Core/Enums/Currency.cs deleted file mode 100644 index 53b79b1..0000000 --- a/src/Mistruna.Core/Enums/Currency.cs +++ /dev/null @@ -1,213 +0,0 @@ -namespace Mistruna.Core.Enums; - -/// -/// ISO 4217 alphabetic currency/funds codes. -/// Underlying enum value is the ISO 4217 numeric code. -/// -public enum Currency : short -{ - Aed = 784, - Afn = 971, - All = 8, - Amd = 51, - Aoa = 973, - Ars = 32, - Aud = 36, - Awg = 533, - Azn = 944, - - Bam = 977, - Bbd = 52, - Bdt = 50, - Bgn = 975, - Bhd = 48, - Bif = 108, - Bmd = 60, - Bnd = 96, - Bob = 68, - Bov = 984, - Brl = 986, - Bsd = 44, - Btn = 64, - Bwp = 72, - Byn = 933, - Bzd = 84, - - Cad = 124, - Cdf = 976, - Che = 947, - Chf = 756, - Chw = 948, - Clf = 990, - Clp = 152, - Cny = 156, - Cop = 170, - Cou = 970, - Crc = 188, - Cup = 192, - Cve = 132, - Czk = 203, - - Djf = 262, - Dkk = 208, - Dop = 214, - Dzd = 12, - - Egp = 818, - Ern = 232, - Etb = 230, - Eur = 978, - - Fjd = 242, - Fkp = 238, - - Gbp = 826, - Gel = 981, - Ghs = 936, - Gip = 292, - Gmd = 270, - Gnf = 324, - Gtq = 320, - Gyd = 328, - - Hkd = 344, - Hnl = 340, - Htg = 332, - Huf = 348, - - Idr = 360, - Ils = 376, - Inr = 356, - Iqd = 368, - Irr = 364, - Isk = 352, - - Jmd = 388, - Jod = 400, - Jpy = 392, - - Kes = 404, - Kgs = 417, - Khr = 116, - Kmf = 174, - Kpw = 408, - Krw = 410, - Kwd = 414, - Kyd = 136, - Kzt = 398, - - Lak = 418, - Lbp = 422, - Lkr = 144, - Lrd = 430, - Lsl = 426, - Lyd = 434, - - Mad = 504, - Mdl = 498, - Mga = 969, - Mkd = 807, - Mmk = 104, - Mnt = 496, - Mop = 446, - Mru = 929, - Mur = 480, - Mvr = 462, - Mwk = 454, - Mxn = 484, - Mxv = 979, - Myr = 458, - Mzn = 943, - - Nad = 516, - Ngn = 566, - Nio = 558, - Nok = 578, - Npr = 524, - Nzd = 554, - - Omr = 512, - - Pab = 590, - Pen = 604, - Pgk = 598, - Php = 608, - Pkr = 586, - Pln = 985, - Pyg = 600, - - Qar = 634, - - Ron = 946, - Rsd = 941, - Rub = 643, - Rwf = 646, - - Sar = 682, - Sbd = 90, - Scr = 690, - Sdg = 938, - Sek = 752, - Sgd = 702, - Shp = 654, - Sle = 925, - Sos = 706, - Srd = 968, - Ssp = 728, - Stn = 930, - Svc = 222, - Syp = 760, - Szl = 748, - - Thb = 764, - Tjs = 972, - Tmt = 934, - Tnd = 788, - Top = 776, - Try = 949, - Ttd = 780, - Twd = 901, - Tzs = 834, - - Uah = 980, - Ugx = 800, - Usd = 840, - Usn = 997, - Uyi = 940, - Uyu = 858, - Uyw = 927, - Uzs = 860, - - Ved = 926, - Ves = 928, - Vnd = 704, - Vuv = 548, - - Wst = 882, - - Xad = 396, - Xaf = 950, - Xag = 961, - Xau = 959, - Xba = 955, - Xbb = 956, - Xbc = 957, - Xbd = 958, - Xcd = 951, - Xcg = 532, - Xdr = 960, - Xof = 952, - Xpd = 964, - Xpf = 953, - Xpt = 962, - Xsu = 994, - Xts = 963, - Xua = 965, - Xxx = 999, - - Yer = 886, - - Zar = 710, - Zmw = 967, - Zwg = 924 -} diff --git a/src/Mistruna.Core/Exceptions/ConflictException.cs b/src/Mistruna.Core/Exceptions/ConflictException.cs index 101da0b..75605b4 100644 --- a/src/Mistruna.Core/Exceptions/ConflictException.cs +++ b/src/Mistruna.Core/Exceptions/ConflictException.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Exceptions; +namespace Mistruna.Core.Exceptions; /// /// Exception thrown when a conflict occurs during resource modification. diff --git a/src/Mistruna.Core/Exceptions/ForbiddenAccessException.cs b/src/Mistruna.Core/Exceptions/ForbiddenAccessException.cs index fe19df4..0015181 100644 --- a/src/Mistruna.Core/Exceptions/ForbiddenAccessException.cs +++ b/src/Mistruna.Core/Exceptions/ForbiddenAccessException.cs @@ -1,4 +1,4 @@ -namespace Mistruna.Core.Exceptions; +namespace Mistruna.Core.Exceptions; /// /// Exception thrown when the user is authenticated but lacks the required permissions. diff --git a/src/Mistruna.Core/Exceptions/NotFoundException.cs b/src/Mistruna.Core/Exceptions/NotFoundException.cs index f020e26..e403b98 100644 --- a/src/Mistruna.Core/Exceptions/NotFoundException.cs +++ b/src/Mistruna.Core/Exceptions/NotFoundException.cs @@ -1,4 +1,4 @@ -using FluentValidation.Results; +using FluentValidation.Results; namespace Mistruna.Core.Exceptions; @@ -8,7 +8,7 @@ namespace Mistruna.Core.Exceptions; /// /// /// Use this exception when an entity or resource cannot be found in the database or storage. -/// The will automatically convert this to an HTTP 404 response. +/// The ASP.NET Core exception handler converts this exception to an HTTP 404 response. /// /// /// diff --git a/src/Mistruna.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Mistruna.Core/Extensions/ApplicationBuilderExtensions.cs deleted file mode 100644 index ab7033e..0000000 --- a/src/Mistruna.Core/Extensions/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Mistruna.Core.Middlewares; -using Mistruna.Core.RateLimiting; -using StackExchange.Redis; - -namespace Mistruna.Core.Extensions; - -/// -/// Extension methods for configuring the application builder. -/// -public static class ApplicationBuilderExtensions -{ - /// - /// Adds the core exception handling middleware to the pipeline. - /// This middleware catches exceptions and returns appropriate HTTP responses. - /// - /// The application builder. - /// The application builder. - /// - /// - /// app.UseCoreMiddlewares(); - /// - /// - public static IApplicationBuilder UseCoreMiddlewares(this IApplicationBuilder app) - { - app.UseMiddleware(); - - return app; - } - - /// - /// Adds the exception handling middleware to the pipeline. - /// This middleware catches exceptions and returns appropriate HTTP responses. - /// - /// The application builder. - /// The application builder. - public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder app) - => app.UseMiddleware(); - - /// - /// Adds the rate limiting middleware to the pipeline. - /// Limits requests per IP address within a configurable time window. - /// Returns HTTP 429 when the limit is exceeded. - /// - /// The application builder. - /// Optional delegate to configure . - /// The application builder. - /// - /// - /// app.UseRateLimiting(opts => - /// { - /// opts.RequestsPerWindow = 60; - /// opts.WindowSeconds = 30; - /// }); - /// - /// - public static IApplicationBuilder UseRateLimiting( - this IApplicationBuilder app, - Action? configure = null) - { - var options = new RateLimitOptions(); - configure?.Invoke(options); - - app.Use((context, next) => - { - var logger = context.RequestServices.GetRequiredService>(); - var redis = context.RequestServices.GetService(); - var middleware = new RateLimitingMiddleware(next, options, logger, redis); - return middleware.InvokeAsync(context); - }); - - return app; - } -} diff --git a/src/Mistruna.Core/Extensions/CollectionExtensions.cs b/src/Mistruna.Core/Extensions/CollectionExtensions.cs deleted file mode 100644 index c5d52e1..0000000 --- a/src/Mistruna.Core/Extensions/CollectionExtensions.cs +++ /dev/null @@ -1,171 +0,0 @@ -namespace Mistruna.Core.Extensions; - -/// -/// Provides extension methods for working with collections. -/// -public static class CollectionExtensions -{ - /// - /// Checks if the collection is null or empty. - /// - /// The type of elements in the collection. - /// The collection to check. - /// True if the collection is null or empty. - public static bool IsNullOrEmpty(this IEnumerable? source) - => source is null || !source.Any(); - - /// - /// Checks if the collection has any elements. - /// - /// The type of elements in the collection. - /// The collection to check. - /// True if the collection has elements. - public static bool HasItems(this IEnumerable? source) - => source is not null && source.Any(); - - /// - /// Returns the collection or an empty collection if null. - /// - /// The type of elements in the collection. - /// The collection. - /// The collection or an empty enumerable. - public static IEnumerable OrEmpty(this IEnumerable? source) - => source ?? Enumerable.Empty(); - - /// - /// Performs an action on each element of the collection. - /// - /// The type of elements in the collection. - /// The collection. - /// The action to perform. - public static void ForEach(this IEnumerable source, Action action) - { - foreach (var item in source) - action(item); - } - - /// - /// Performs an action on each element with its index. - /// - /// The type of elements in the collection. - /// The collection. - /// The action to perform with index. - public static void ForEach(this IEnumerable source, Action action) - { - var index = 0; - foreach (var item in source) - action(item, index++); - } - - /// - /// Splits a collection into batches of a specified size. - /// - /// The type of elements in the collection. - /// The collection to split. - /// The size of each batch. - /// An enumerable of batches. - public static IEnumerable> Batch(this IEnumerable source, int batchSize) - { - var batch = new List(batchSize); - foreach (var item in source) - { - batch.Add(item); - if (batch.Count == batchSize) - { - yield return batch; - batch = new List(batchSize); - } - } - - if (batch.Count > 0) - yield return batch; - } - -#if NETSTANDARD2_0 - /// - /// Removes duplicates based on a key selector. - /// Available in .NET 6+ as System.Linq.Enumerable.DistinctBy - /// - /// The type of elements in the collection. - /// The type of the key. - /// The collection. - /// The key selector function. - /// A collection with duplicates removed. - public static IEnumerable DistinctBy( - this IEnumerable source, - Func keySelector) - { - var seen = new HashSet(); - foreach (var element in source) - { - if (seen.Add(keySelector(element))) - yield return element; - } - } -#endif - - /// - /// Converts a collection to a HashSet. - /// - /// The type of elements in the collection. - /// The collection. - /// A new HashSet containing the elements. - public static HashSet ToHashSet(this IEnumerable source) - => new(source); - - /// - /// Converts a collection to a HashSet with a custom comparer. - /// - /// The type of elements in the collection. - /// The collection. - /// The equality comparer. - /// A new HashSet containing the elements. - public static HashSet ToHashSet(this IEnumerable source, IEqualityComparer comparer) - => new(source, comparer); - - /// - /// Filters out null values from a collection. - /// - /// The type of elements in the collection. - /// The collection. - /// A collection without null values. - public static IEnumerable WhereNotNull(this IEnumerable source) where T : class - => source.Where(x => x is not null)!; - - /// - /// Returns a random element from the collection. - /// - /// The type of elements in the collection. - /// The collection. - /// A random element. - public static T? RandomElement(this IList source) - => source.Count == 0 ? default : source[Random.Shared.Next(source.Count)]; - - /// - /// Appends an item to the collection. - /// - /// The type of elements in the collection. - /// The collection. - /// The item to append. - /// The collection with the item appended. - public static IEnumerable Append(this IEnumerable source, T item) - { - foreach (var element in source) - yield return element; - yield return item; - } - - /// - /// Prepends an item to the collection. - /// - /// The type of elements in the collection. - /// The collection. - /// The item to prepend. - /// The collection with the item prepended. - public static IEnumerable Prepend(this IEnumerable source, T item) - { - yield return item; - foreach (var element in source) - yield return element; - } -} diff --git a/src/Mistruna.Core/Extensions/CorsExtensions.cs b/src/Mistruna.Core/Extensions/CorsExtensions.cs deleted file mode 100644 index ef081a4..0000000 --- a/src/Mistruna.Core/Extensions/CorsExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.DependencyInjection; -using Mistruna.Core.Microservices.Core.Configurations; - -namespace Mistruna.Core.Extensions; - -/// -/// Provides extension methods for configuring CORS policies in the DI container. -/// -public static class CorsExtensions -{ - /// - /// Adds CORS policy configuration to the service collection. - /// - /// The service collection. - /// The CORS policy options. - /// The name of the CORS policy. Defaults to "AllowAll". - /// The service collection. - /// Thrown when is null. - public static IServiceCollection AddCorsPolicy( - this IServiceCollection services, - CorsPolicyOptions corsPolicy, - string policyName = "AllowAll") - { - ArgumentNullException.ThrowIfNull(corsPolicy); - - services.AddCors(options => options.AddPolicy(policyName, corsPolicyBuilder => - { - ConfigureOrigins(corsPolicyBuilder, corsPolicy.AllowAnyOrigin, corsPolicy.AllowedOrigins); - ConfigureMethods(corsPolicyBuilder, corsPolicy.AllowAnyMethod, corsPolicy.AllowedMethods); - ConfigureHeaders(corsPolicyBuilder, corsPolicy.AllowAnyHeader, corsPolicy.AllowedHeaders); - - if (corsPolicy.AllowCredentials) - { - corsPolicyBuilder.AllowCredentials(); - } - - })); - - return services; - } - - private static void ConfigureOrigins(CorsPolicyBuilder builder, bool allowAny, List? allowed) - { - if (allowAny || allowed is not { Count: > 0 }) - { - builder.AllowAnyOrigin(); - } - else - { - builder.WithOrigins(allowed.ToArray()); - } - } - - private static void ConfigureMethods(CorsPolicyBuilder builder, bool allowAny, List? allowed) - { - if (allowAny || allowed is not { Count: > 0 }) - { - builder.AllowAnyMethod(); - } - else - { - builder.WithMethods(allowed.ToArray()); - } - } - - private static void ConfigureHeaders(CorsPolicyBuilder builder, bool allowAny, List? allowed) - { - if (allowAny || allowed is not { Count: > 0 }) - { - builder.AllowAnyHeader(); - } - else - { - builder.WithHeaders(allowed.ToArray()); - } - } -} diff --git a/src/Mistruna.Core/Extensions/CurrencyExtensions.cs b/src/Mistruna.Core/Extensions/CurrencyExtensions.cs deleted file mode 100644 index 4ed5cc6..0000000 --- a/src/Mistruna.Core/Extensions/CurrencyExtensions.cs +++ /dev/null @@ -1,425 +0,0 @@ -using Mistruna.Core.Contracts.Models; -using Mistruna.Core.Enums; - -namespace Mistruna.Core.Extensions; - -/// -/// Provides extension methods for working with values. -/// -public static class CurrencyExtensions -{ - /// - extension(Currency currency) - { - /// - /// Gets the ISO 4217 alphabetic currency code (e.g., "USD"). - /// - public string GetCode() => currency.ToString().ToUpperInvariant(); - - /// - /// Gets the ISO 4217 numeric currency code as a . - /// - public short GetNumeric() => (short)currency; - - /// - /// Gets the full descriptive name of the currency (e.g., "US Dollar"). - /// - /// - /// Gets the full descriptive name of the currency (e.g., "US Dollar"). - /// - public string GetFullName() - => currency switch - { - Currency.Aed => "UAE Dirham", - Currency.Afn => "Afghani", - Currency.All => "Lek", - Currency.Amd => "Armenian Dram", - Currency.Aoa => "Kwanza", - Currency.Ars => "Argentine Peso", - Currency.Aud => "Australian Dollar", - Currency.Awg => "Aruban Florin", - Currency.Azn => "Azerbaijan Manat", - - Currency.Bam => "Convertible Mark", - Currency.Bbd => "Barbados Dollar", - Currency.Bdt => "Taka", - Currency.Bgn => "Bulgarian Lev", - Currency.Bhd => "Bahraini Dinar", - Currency.Bif => "Burundi Franc", - Currency.Bmd => "Bermudian Dollar", - Currency.Bnd => "Brunei Dollar", - Currency.Bob => "Boliviano", - Currency.Bov => "Mvdol", - Currency.Brl => "Brazilian Real", - Currency.Bsd => "Bahamian Dollar", - Currency.Btn => "Ngultrum", - Currency.Bwp => "Pula", - Currency.Byn => "Belarusian Ruble", - Currency.Bzd => "Belize Dollar", - - Currency.Cad => "Canadian Dollar", - Currency.Cdf => "Congolese Franc", - Currency.Che => "WIR Euro", - Currency.Chf => "Swiss Franc", - Currency.Chw => "WIR Franc", - Currency.Clf => "Unidad de Fomento", - Currency.Clp => "Chilean Peso", - Currency.Cny => "Yuan Renminbi", - Currency.Cop => "Colombian Peso", - Currency.Cou => "Unidad de Valor Real", - Currency.Crc => "Costa Rican Colon", - Currency.Cup => "Cuban Peso", - Currency.Cve => "Cape Verde Escudo", - Currency.Czk => "Czech Koruna", - - Currency.Djf => "Djibouti Franc", - Currency.Dkk => "Danish Krone", - Currency.Dop => "Dominican Peso", - Currency.Dzd => "Algerian Dinar", - - Currency.Egp => "Egyptian Pound", - Currency.Ern => "Nakfa", - Currency.Etb => "Ethiopian Birr", - Currency.Eur => "Euro", - - Currency.Fjd => "Fiji Dollar", - Currency.Fkp => "Falkland Islands Pound", - - Currency.Gbp => "Pound Sterling", - Currency.Gel => "Lari", - Currency.Ghs => "Ghana Cedi", - Currency.Gip => "Gibraltar Pound", - Currency.Gmd => "Dalasi", - Currency.Gnf => "Guinea Franc", - Currency.Gtq => "Quetzal", - Currency.Gyd => "Guyana Dollar", - - Currency.Hkd => "Hong Kong Dollar", - Currency.Hnl => "Lempira", - Currency.Htg => "Gourde", - Currency.Huf => "Forint", - - Currency.Idr => "Rupiah", - Currency.Ils => "New Israeli Sheqel", - Currency.Inr => "Indian Rupee", - Currency.Iqd => "Iraqi Dinar", - Currency.Irr => "Iranian Rial", - Currency.Isk => "Iceland Krona", - - Currency.Jmd => "Jamaican Dollar", - Currency.Jod => "Jordanian Dinar", - Currency.Jpy => "Yen", - - Currency.Kes => "Kenyan Shilling", - Currency.Kgs => "Som", - Currency.Khr => "Riel", - Currency.Kmf => "Comoro Franc", - Currency.Kpw => "North Korean Won", - Currency.Krw => "Won", - Currency.Kwd => "Kuwaiti Dinar", - Currency.Kyd => "Cayman Islands Dollar", - Currency.Kzt => "Tenge", - - Currency.Lak => "Kip", - Currency.Lbp => "Lebanese Pound", - Currency.Lkr => "Sri Lanka Rupee", - Currency.Lrd => "Liberian Dollar", - Currency.Lsl => "Loti", - Currency.Lyd => "Libyan Dinar", - - Currency.Mad => "Moroccan Dirham", - Currency.Mdl => "Moldovan Leu", - Currency.Mga => "Malagasy Ariary", - Currency.Mkd => "Denar", - Currency.Mmk => "Kyat", - Currency.Mnt => "Tugrik", - Currency.Mop => "Pataca", - Currency.Mru => "Ouguiya", - Currency.Mur => "Mauritius Rupee", - Currency.Mvr => "Rufiyaa", - Currency.Mwk => "Malawi Kwacha", - Currency.Mxn => "Mexican Peso", - Currency.Mxv => "Mexican Unidad de Inversion (UDI)", - Currency.Myr => "Malaysian Ringgit", - Currency.Mzn => "Mozambique Metical", - - Currency.Nad => "Namibia Dollar", - Currency.Ngn => "Naira", - Currency.Nio => "Cordoba Oro", - Currency.Nok => "Norwegian Krone", - Currency.Npr => "Nepalese Rupee", - Currency.Nzd => "New Zealand Dollar", - - Currency.Omr => "Rial Omani", - - Currency.Pab => "Balboa", - Currency.Pen => "Sol", - Currency.Pgk => "Kina", - Currency.Php => "Philippine Peso", - Currency.Pkr => "Pakistan Rupee", - Currency.Pln => "Zloty", - Currency.Pyg => "Guarani", - - Currency.Qar => "Qatari Rial", - - Currency.Ron => "Romanian Leu", - Currency.Rsd => "Serbian Dinar", - Currency.Rub => "Russian Ruble", - Currency.Rwf => "Rwanda Franc", - - Currency.Sar => "Saudi Riyal", - Currency.Sbd => "Solomon Islands Dollar", - Currency.Scr => "Seychelles Rupee", - Currency.Sdg => "Sudanese Pound", - Currency.Sek => "Swedish Krona", - Currency.Sgd => "Singapore Dollar", - Currency.Shp => "Saint Helena Pound", - Currency.Sle => "Leone", - Currency.Sos => "Somali Shilling", - Currency.Srd => "Surinam Dollar", - Currency.Ssp => "South Sudanese Pound", - Currency.Stn => "Dobra", - Currency.Svc => "El Salvador Colon", - Currency.Syp => "Syrian Pound", - Currency.Szl => "Lilangeni", - - Currency.Thb => "Baht", - Currency.Tjs => "Somoni", - Currency.Tmt => "Turkmenistan New Manat", - Currency.Tnd => "Tunisian Dinar", - Currency.Top => "Pa’anga", - Currency.Try => "Turkish Lira", - Currency.Ttd => "Trinidad and Tobago Dollar", - Currency.Twd => "New Taiwan Dollar", - Currency.Tzs => "Tanzanian Shilling", - - Currency.Uah => "Hryvnia", - Currency.Ugx => "Uganda Shilling", - Currency.Usd => "US Dollar", - Currency.Usn => "US Dollar (Next day)", - Currency.Uyi => "Uruguay Peso en Unidades Indexadas", - Currency.Uyu => "Peso Uruguayo", - Currency.Uyw => "Unidad Previsional", - Currency.Uzs => "Uzbekistan Sum", - - Currency.Ved => "Venezuelan Digital Bolívar", - Currency.Ves => "Venezuelan Sovereign Bolívar", - Currency.Vnd => "Dong", - Currency.Vuv => "Vatu", - - Currency.Wst => "Tala", - - Currency.Xad => "Arab Accounting Dinar", - Currency.Xaf => "CFA Franc BEAC", - Currency.Xag => "Silver", - Currency.Xau => "Gold", - Currency.Xba => "Bond Markets Unit European Composite Unit (EURCO)", - Currency.Xbb => "Bond Markets Unit European Monetary Unit (E.M.U.-6)", - Currency.Xbc => "Bond Markets Unit European Unit of Account 9 (E.U.A.-9)", - Currency.Xbd => "Bond Markets Unit European Unit of Account 17 (E.U.A.-17)", - Currency.Xcd => "East Caribbean Dollar", - Currency.Xcg => "Caribbean Guilder", - Currency.Xdr => "SDR (Special Drawing Right)", - Currency.Xof => "CFA Franc BCEAO", - Currency.Xpd => "Palladium", - Currency.Xpf => "CFP Franc", - Currency.Xpt => "Platinum", - Currency.Xsu => "Sucre", - Currency.Xts => "Codes Reserved for Testing Purposes", - Currency.Xua => "ADB Unit of Account", - Currency.Xxx => "No Currency", - - Currency.Yer => "Yemeni Rial", - - Currency.Zar => "Rand", - Currency.Zmw => "Zambian Kwacha", - Currency.Zwg => "Zimbabwe Gold", - - _ => throw new ArgumentOutOfRangeException(nameof(currency), currency, "Unsupported currency.") - }; - - /// - /// Gets the currency symbol used in display (e.g., "$", "€", "¥"). - /// - /// - /// Gets the currency symbol used in display (e.g., "$", "€", "¥"). - /// Returns the alphabetic code as a fallback for currencies without a dedicated symbol. - /// - public string GetSymbol() - => currency switch - { - Currency.Usd or Currency.Cad or Currency.Aud or Currency.Nzd or Currency.Sgd or Currency.Hkd - or Currency.Bzd or Currency.Fjd or Currency.Gyd or Currency.Lrd or Currency.Nad - or Currency.Bbd or Currency.Bmd or Currency.Bnd or Currency.Bsd or Currency.Kyd - or Currency.Xcd or Currency.Srd or Currency.Ttd - => "$", - - Currency.Eur or Currency.Che => "€", - Currency.Gbp or Currency.Fkp or Currency.Gip or Currency.Sdg or Currency.Ssp or Currency.Shp - => "£", - Currency.Jpy or Currency.Cny => "¥", - Currency.Rub => "₽", - Currency.Uah => "₴", - Currency.Pln => "zł", - Currency.Try => "₺", - Currency.Inr => "₹", - Currency.Krw or Currency.Kpw => "₩", - Currency.Ils => "₪", - Currency.Php => "₱", - Currency.Vnd => "₫", - Currency.Lak => "₭", - Currency.Khr => "៛", - Currency.Mnt => "₮", - Currency.Kzt => "₸", - Currency.Twd => "NT$", - Currency.Thb => "฿", - Currency.Myr => "RM", - Currency.Idr => "Rp", - Currency.Aed => "د.إ", - Currency.Afn => "؋", - Currency.All => "L", - Currency.Amd => "֏", - Currency.Aoa => "Kz", - Currency.Azn => "₼", - Currency.Bam => "KM", - Currency.Bdt => "৳", - Currency.Bgn => "лв", - Currency.Bhd => "ب.د", - Currency.Bif => "FBu", - Currency.Bob => "Bs", - Currency.Brl => "R$", - Currency.Btn => "Nu.", - Currency.Bwp => "P", - Currency.Byn => "Br", - Currency.Chf or Currency.Chw => "CHF", - Currency.Clp => "$", - Currency.Cop => "$", - Currency.Crc => "₡", - Currency.Cup => "$", - Currency.Cve => "$", - Currency.Czk => "Kč", - Currency.Dkk or Currency.Nok or Currency.Sek or Currency.Isk => "kr", - Currency.Dop => "RD$", - Currency.Dzd => "د.ج", - Currency.Egp => "£", - Currency.Ern => "Nfk", - Currency.Etb => "Br", - Currency.Gel => "₾", - Currency.Ghs => "₵", - Currency.Gmd => "D", - Currency.Gnf => "FG", - Currency.Gtq => "Q", - Currency.Hnl => "L", - Currency.Htg => "G", - Currency.Huf => "Ft", - Currency.Iqd => "ع.د", - Currency.Irr => "﷼", - Currency.Jmd => "J$", - Currency.Jod => "JD", - Currency.Kes => "KSh", - Currency.Kgs => "сом", - Currency.Kmf => "CF", - Currency.Kwd => "KD", - Currency.Lbp => "ل.ل", - Currency.Lkr => "Rs", - Currency.Lsl => "L", - Currency.Lyd => "ل.د", - Currency.Mad => "د.م.", - Currency.Mdl => "L", - Currency.Mga => "Ar", - Currency.Mkd => "ден", - Currency.Mmk => "K", - Currency.Mop => "MOP$", - Currency.Mru => "UM", - Currency.Mur => "₨", - Currency.Mvr => "Rf", - Currency.Mwk => "MK", - Currency.Mxn => "$", - Currency.Mzn => "MT", - Currency.Ngn => "₦", - Currency.Nio => "C$", - Currency.Npr => "₨", - Currency.Omr => "ر.ع.", - Currency.Pab => "B/.", - Currency.Pen => "S/", - Currency.Pgk => "K", - Currency.Pkr => "₨", - Currency.Pyg => "₲", - Currency.Qar => "ر.ق", - Currency.Ron => "lei", - Currency.Rsd => "дин.", - Currency.Rwf => "FRw", - Currency.Sar => "ر.س", - Currency.Scr => "₨", - Currency.Sle => "Le", - Currency.Sos => "Sh", - Currency.Stn => "Db", - Currency.Svc => "₡", - Currency.Syp => "£", - Currency.Szl => "E", - Currency.Tjs => "ЅМ", - Currency.Tmt => "m", - Currency.Tnd => "د.ت", - Currency.Top => "T$", - Currency.Tzs => "Sh", - Currency.Ugx => "Sh", - Currency.Uyu => "$U", - Currency.Uzs => "so'm", - Currency.Ved or Currency.Ves => "Bs.", - Currency.Vuv => "VT", - Currency.Wst => "WS$", - Currency.Xaf or Currency.Xof or Currency.Xpf => "F", - Currency.Xag => "XAG", - Currency.Xau => "XAU", - Currency.Xpd => "XPD", - Currency.Xpt => "XPT", - Currency.Yer => "﷼", - Currency.Zar => "R", - Currency.Zmw => "ZK", - Currency.Zwg => "ZiG", - - _ => currency.GetCode() - }; - - /// - /// Gets a structured record containing all currency metadata. - /// - /// - /// Gets a structured record containing all currency metadata. - /// - public CurrencyInfo GetInfo() - => new( - Code: currency.GetCode(), - Numeric: currency.GetNumeric(), - FullName: currency.GetFullName(), - Symbol: currency.GetSymbol()); - } - - /// - /// Attempts to parse a currency code string into a value. - /// - /// The currency code to parse (e.g., "USD"). - /// The parsed currency value. - /// True if parsing succeeded, otherwise false. - public static bool TryParse(string code, out Currency currency) - { - if (!string.IsNullOrWhiteSpace(code)) - { - return Enum.TryParse(code, ignoreCase: true, out currency); - } - - currency = default; - return false; - } - - /// - /// Parses a currency code string into a value. - /// - /// The currency code to parse (e.g., "USD"). - /// The parsed currency value. - /// Thrown when the code is not a valid currency. - public static Currency Parse(string code) - => TryParse(code, out var currency) - ? currency - : throw new ArgumentException($"Invalid currency code: '{code}'.", nameof(code)); -} diff --git a/src/Mistruna.Core/Extensions/HealthChecksServiceCollectionExtensions.cs b/src/Mistruna.Core/Extensions/HealthChecksServiceCollectionExtensions.cs deleted file mode 100644 index 10f4573..0000000 --- a/src/Mistruna.Core/Extensions/HealthChecksServiceCollectionExtensions.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Mistruna.Core.HealthChecks; - -namespace Mistruna.Core.Extensions; - -/// -/// Opinionated health check registration helpers for ASP.NET Core applications. -/// -public static class HealthChecksServiceCollectionExtensions -{ - /// - /// Registers the default Mistruna.Core health checks. - /// - /// The service collection. - /// Optional callback to add more checks to the builder. - /// The service collection. - /// - /// Always adds a lightweight self check named self. - /// Use and - /// for infrastructure checks. - /// - public static IServiceCollection AddCoreHealthChecks( - this IServiceCollection services, - Action? configure = null) - { - var builder = services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]); - - configure?.Invoke(builder); - return services; - } - - /// - /// Adds an EF Core database connectivity check for the given . - /// - public static IHealthChecksBuilder AddDatabaseHealthCheck( - this IHealthChecksBuilder builder, - string name = "database", - HealthStatus? failureStatus = null, - IEnumerable? tags = null) - where TDbContext : DbContext - { - return builder.AddCheck>( - name, - failureStatus ?? HealthStatus.Unhealthy, - tags ?? ["ready", "db", "infrastructure"]); - } -} diff --git a/src/Mistruna.Core/Extensions/PersistenceServiceCollectionExtensions.cs b/src/Mistruna.Core/Extensions/PersistenceServiceCollectionExtensions.cs deleted file mode 100644 index 961032a..0000000 --- a/src/Mistruna.Core/Extensions/PersistenceServiceCollectionExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Mistruna.Core.Base.Persistence; -using Mistruna.Core.Contracts.Base.Infrastructure; - -namespace Mistruna.Core.Extensions; - -/// -/// Extension methods for registering persistence infrastructure services. -/// -public static class PersistenceServiceCollectionExtensions -{ - /// - /// Registers the EF Core unit of work for the given DbContext. - /// - /// The EF Core DbContext type. - /// The service collection. - /// The service collection. - public static IServiceCollection AddEfUnitOfWork(this IServiceCollection services) - where TContext : DbContext - { - services.TryAddScoped>(); - return services; - } -} diff --git a/src/Mistruna.Core/Extensions/RedisServiceCollectionExtensions.cs b/src/Mistruna.Core/Extensions/RedisServiceCollectionExtensions.cs deleted file mode 100644 index 3ebab04..0000000 --- a/src/Mistruna.Core/Extensions/RedisServiceCollectionExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Mistruna.Core.Caching; -using Mistruna.Core.Contracts.Base.Infrastructure; -using Mistruna.Core.HealthChecks; -using StackExchange.Redis; - -namespace Mistruna.Core.Extensions; - -/// -/// Extension methods for registering Redis caching and health check services. -/// -public static class RedisServiceCollectionExtensions -{ - /// - /// Registers a Redis singleton and the - /// as the implementation. - /// - /// The service collection. - /// The Redis connection string (e.g., "localhost:6379"). - /// Optional delegate to configure . - /// The service collection. - /// - /// - /// builder.Services.AddRedisCaching("localhost:6379", opts => - /// { - /// opts.KeyPrefix = "myapp"; - /// opts.DefaultExpiration = TimeSpan.FromMinutes(10); - /// }); - /// - /// - public static IServiceCollection AddRedisCaching( - this IServiceCollection services, - string connectionString, - Action? configureCacheOptions = null) - { - var cacheOptions = new CacheOptions(); - configureCacheOptions?.Invoke(cacheOptions); - - services.AddSingleton(cacheOptions); - - services.AddSingleton(_ => - ConnectionMultiplexer.Connect(connectionString)); - - services.AddSingleton(); - - return services; - } - - /// - /// Adds a Redis health check to the health checks builder. - /// - /// The health checks builder. - /// The health check name. Defaults to "redis". - /// The failure status. Defaults to . - /// Optional tags for the health check. - /// The health checks builder. - /// - /// - /// builder.Services.AddHealthChecks() - /// .AddRedisHealthCheck(); - /// - /// - public static IHealthChecksBuilder AddRedisHealthCheck( - this IHealthChecksBuilder builder, - string name = "redis", - HealthStatus? failureStatus = null, - IEnumerable? tags = null) - { - return builder.AddCheck( - name, - failureStatus ?? HealthStatus.Unhealthy, - tags ?? ["redis", "cache", "infrastructure"]); - } -} diff --git a/src/Mistruna.Core/Extensions/ServiceCollectionExtensions.cs b/src/Mistruna.Core/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index 7f426ef..0000000 --- a/src/Mistruna.Core/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System.Reflection; -using FluentValidation; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Mistruna.Core.Filters; - -namespace Mistruna.Core.Extensions; - -/// -/// Extension methods for configuring services in the DI container. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Adds all Mistruna.Core services including MediatR, FluentValidation, and logging behaviors. - /// - /// The service collection. - /// Assemblies to scan for handlers and validators. - /// The service collection. - /// - /// Mark write requests with and read requests with - /// so cross-cutting behaviors can target commands only. - /// - /// - /// - /// builder.Services.AddCore(typeof(Program).Assembly); - /// - /// - public static IServiceCollection AddCore(this IServiceCollection services, params Assembly[] assemblies) - { - services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(assemblies)); - services.AddValidators(assemblies); - services.AddCoreBehaviors(); - - return services; - } - - /// - /// Adds all validators from the specified assemblies. - /// - /// The service collection. - /// Assemblies to scan for validators. - /// The service collection. - public static IServiceCollection AddValidators(this IServiceCollection services, params Assembly[] assemblies) - { - var validatorTypes = assemblies - .SelectMany(assembly => assembly.GetTypes() - .Where(t => !t.IsAbstract && !t.IsGenericTypeDefinition) - .SelectMany(t => t.GetInterfaces() - .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>)) - .Select(i => new { Interface = i, Implementation = t }))); - - foreach (var validator in validatorTypes) - { - services.AddTransient(validator.Interface, validator.Implementation); - } - - return services; - } - - /// - /// Adds the standard MediatR pipeline behaviors. - /// - /// The service collection. - /// The service collection. - public static IServiceCollection AddCoreBehaviors(this IServiceCollection services) - { - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); - - return services; - } - - /// - /// Adds only the validation behavior without logging. - /// - /// The service collection. - /// The service collection. - public static IServiceCollection AddValidationBehavior(this IServiceCollection services) - { - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); - return services; - } - - /// - /// Adds a service as a singleton if it doesn't already exist. - /// - /// The service type. - /// The implementation type. - /// The service collection. - /// The service collection. - public static IServiceCollection TryAddSingleton(this IServiceCollection services) - where TService : class - where TImplementation : class, TService - { - if (!services.Any(x => x.ServiceType == typeof(TService))) - { - services.AddSingleton(); - } - - return services; - } - - /// - /// Adds a service as scoped if it doesn't already exist. - /// - /// The service type. - /// The implementation type. - /// The service collection. - /// The service collection. - public static IServiceCollection TryAddScoped(this IServiceCollection services) - where TService : class - where TImplementation : class, TService - { - if (!services.Any(x => x.ServiceType == typeof(TService))) - { - services.AddScoped(); - } - - return services; - } - - /// - /// Adds a service as transient if it doesn't already exist. - /// - /// The service type. - /// The implementation type. - /// The service collection. - /// The service collection. - public static IServiceCollection TryAddTransient(this IServiceCollection services) - where TService : class - where TImplementation : class, TService - { - if (!services.Any(x => x.ServiceType == typeof(TService))) - { - services.AddTransient(); - } - - return services; - } - - /// - /// Decorates a service with a decorator. - /// - /// The service type. - /// The decorator type. - /// The service collection. - /// The service collection. - public static IServiceCollection Decorate(this IServiceCollection services) - where TService : class - where TDecorator : class, TService - { - var wrappedDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(TService)) - ?? throw new InvalidOperationException($"Service {typeof(TService).Name} is not registered."); - - var objectFactory = ActivatorUtilities.CreateFactory( - typeof(TDecorator), - [typeof(TService)]); - - services.Add(ServiceDescriptor.Describe( - typeof(TService), - sp => (TService)objectFactory(sp, [sp.CreateInstance(wrappedDescriptor)]), - wrappedDescriptor.Lifetime)); - - services.Remove(wrappedDescriptor); - - return services; - } - - private static object CreateInstance(this IServiceProvider services, ServiceDescriptor descriptor) - { - if (descriptor.ImplementationInstance != null) - { - return descriptor.ImplementationInstance; - } - - if (descriptor.ImplementationFactory != null) - { - return descriptor.ImplementationFactory(services); - } - - return ActivatorUtilities.GetServiceOrCreateInstance( - services, - descriptor.ImplementationType!); - } -} diff --git a/src/Mistruna.Core/Filters/ConfigureSwaggerOptions.cs b/src/Mistruna.Core/Filters/ConfigureSwaggerOptions.cs deleted file mode 100644 index ee4bca9..0000000 --- a/src/Mistruna.Core/Filters/ConfigureSwaggerOptions.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Text; -using Asp.Versioning.ApiExplorer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Mistruna.Core.Filters; - -/// -/// Configures the Swagger generation options. -/// -/// This allows API versioning to define a Swagger document per API version after the -/// service has been resolved from the service container. -public class ConfigureSwaggerOptions : IConfigureOptions -{ - private readonly IApiVersionDescriptionProvider _provider; - - /// - /// Initializes a new instance of the class. - /// - /// The provider used to generate Swagger - /// documents. - public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) => _provider = provider; - - /// - public void Configure(SwaggerGenOptions options) - { - // add a swagger document for each discovered API version - // note: you might choose to skip or document deprecated API versions differently - foreach (var description in _provider.ApiVersionDescriptions) - { - options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); - } - } - - private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) - { - var text = new StringBuilder("An example application with OpenAPI, Swashbuckle, and API versioning."); - var info = new OpenApiInfo - { - Title = "Example API", - Version = description.ApiVersion.ToString(), - Contact = new OpenApiContact { Name = "Bill Mei", Email = "bill.mei@somewhere.com" }, - License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") } - }; - - if (description.IsDeprecated) - { - text.Append(" This API version has been deprecated."); - } - - if (description.SunsetPolicy is { } policy) - { - if (policy.Date is { } when) - { - text.Append(" The API will be sunset on ") - .Append(when.Date.ToShortDateString()) - .Append('.'); - } - - if (policy.HasLinks) - { - text.AppendLine(); - - foreach (var link in policy.Links) - { - if (link.Type != "text/html") - { - continue; - } - - text.AppendLine(); - - if (link.Title.HasValue) - { - text.Append(link.Title.Value).Append(": "); - } - - text.Append(link.LinkTarget.OriginalString); - } - } - } - - info.Description = text.ToString(); - - return info; - } -} diff --git a/src/Mistruna.Core/Filters/LoggingBehavior.cs b/src/Mistruna.Core/Filters/LoggingBehavior.cs deleted file mode 100644 index 076d16f..0000000 --- a/src/Mistruna.Core/Filters/LoggingBehavior.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Diagnostics; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace Mistruna.Core.Filters; - -/// -/// MediatR pipeline behavior that logs request execution time and details. -/// -/// The type of request. -/// The type of response. -/// -/// This behavior logs the start and completion of each request, including execution time. -/// Requests that take longer than 500ms are logged as warnings. -/// -/// Register this behavior in your DI container: -/// -/// services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); -/// -/// -public class LoggingBehavior(ILogger> logger) - : IPipelineBehavior - where TRequest : IRequest -{ - private const int SlowRequestThresholdMs = 500; - - /// - /// Handles the logging of request execution. - /// - /// The request being handled. - /// The next handler in the pipeline. - /// The cancellation token. - /// The response from the next handler. - public async Task Handle( - TRequest request, - RequestHandlerDelegate next, - CancellationToken cancellationToken) - { - var requestName = typeof(TRequest).Name; - var requestId = Guid.NewGuid().ToString("N")[..8]; - - logger.LogInformation( - "[{RequestId}] Starting request {RequestName}", - requestId, requestName); - - var stopwatch = Stopwatch.StartNew(); - - try - { - var response = await next(cancellationToken); - - stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; - - if (elapsedMs > SlowRequestThresholdMs) - { - logger.LogWarning( - "[{RequestId}] Slow request {RequestName} completed in {ElapsedMs}ms", - requestId, requestName, elapsedMs); - } - else - { - logger.LogInformation( - "[{RequestId}] Completed request {RequestName} in {ElapsedMs}ms", - requestId, requestName, elapsedMs); - } - - return response; - } - catch (Exception ex) - { - stopwatch.Stop(); - logger.LogError(ex, - "[{RequestId}] Request {RequestName} failed after {ElapsedMs}ms", - requestId, requestName, stopwatch.ElapsedMilliseconds); - throw; - } - } -} diff --git a/src/Mistruna.Core/Filters/PathPrefixInsertDocumentFilter.cs b/src/Mistruna.Core/Filters/PathPrefixInsertDocumentFilter.cs deleted file mode 100644 index 6bc8932..0000000 --- a/src/Mistruna.Core/Filters/PathPrefixInsertDocumentFilter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Mistruna.Core.Filters; - -/// -/// Swagger document filter that prepends a path prefix to all API routes. -/// Useful when the API is hosted under a sub-path (e.g., "/api/v1"). -/// -public class PathPrefixInsertDocumentFilter(string prefix) : IDocumentFilter -{ - public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) - { - var paths = swaggerDoc.Paths.Keys.ToList(); - foreach (var path in paths) - { - var pathToChange = swaggerDoc.Paths[path]; - swaggerDoc.Paths.Remove(path); - swaggerDoc.Paths.Add($"{prefix}{path}", pathToChange); - } - } -} diff --git a/src/Mistruna.Core/Filters/RequestValidationBehavior.cs b/src/Mistruna.Core/Filters/RequestValidationBehavior.cs deleted file mode 100644 index e45d940..0000000 --- a/src/Mistruna.Core/Filters/RequestValidationBehavior.cs +++ /dev/null @@ -1,74 +0,0 @@ -using FluentValidation; -using MediatR; - -namespace Mistruna.Core.Filters; - -/// -/// MediatR pipeline behavior that validates requests using FluentValidation validators. -/// -/// The type of request being validated. -/// The type of response returned by the handler. -/// -/// This behavior runs all registered validators for a request type before the request handler is executed. -/// If any validation failures occur, a is thrown. -/// -/// Register this behavior in your DI container: -/// -/// services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); -/// -/// -/// -/// -/// // Create a validator for your request -/// public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand> -/// { -/// public CreateUserCommandValidator() -/// { -/// RuleFor(x => x.Email).NotEmpty().EmailAddress(); -/// RuleFor(x => x.Name).NotEmpty().MaximumLength(100); -/// } -/// } -/// -/// -public class RequestValidationBehavior(IEnumerable> validators) - : IPipelineBehavior where TRequest : class -{ - /// - /// Handles the validation of the request before passing it to the next handler in the pipeline. - /// - /// The request to validate. - /// The delegate for the next action in the pipeline. - /// The cancellation token. - /// The response from the next handler in the pipeline. - /// Thrown when validation fails. - public async Task Handle( - TRequest request, - RequestHandlerDelegate next, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(next); - - if (!validators.Any()) - { - return await next(cancellationToken); - } - - var context = new ValidationContext(request); - - var validationResults = await Task.WhenAll( - validators.Select(v => - v.ValidateAsync(context, cancellationToken))); - - var failures = validationResults - .Where(r => r.Errors.Count > 0) - .SelectMany(r => r.Errors) - .ToList(); - - if (failures.Count > 0) - { - throw new ValidationException(failures); - } - - return await next(cancellationToken); - } -} diff --git a/src/Mistruna.Core/Filters/ResultValidationBehavior.cs b/src/Mistruna.Core/Filters/ResultValidationBehavior.cs deleted file mode 100644 index 4152160..0000000 --- a/src/Mistruna.Core/Filters/ResultValidationBehavior.cs +++ /dev/null @@ -1,76 +0,0 @@ -using FluentValidation; -using MediatR; -using Mistruna.Core.Contracts.Base.Results; - -namespace Mistruna.Core.Filters; - -/// -/// A MediatR pipeline behavior that validates requests using FluentValidation -/// and returns a Result with validation errors instead of throwing exceptions. -/// -/// The request type. -/// The response type (must be a Result). -public class ResultValidationBehavior : IPipelineBehavior - where TRequest : IRequest - where TResponse : class -{ - private readonly IEnumerable> _validators; - - /// - /// Initializes a new instance of the class. - /// - /// The validators for the request. - public ResultValidationBehavior(IEnumerable> validators) - { - _validators = validators; - } - - /// - public async Task Handle( - TRequest request, - RequestHandlerDelegate next, - CancellationToken cancellationToken) - { - if (!_validators.Any()) - { - return await next(cancellationToken); - } - - var context = new ValidationContext(request); - - var validationResults = await Task.WhenAll( - _validators.Select(v => v.ValidateAsync(context, cancellationToken))); - - var failures = validationResults - .SelectMany(r => r.Errors) - .Where(f => f != null) - .ToList(); - - if (failures.Count != 0) - { - return CreateValidationErrorResult(failures); - } - - return await next(cancellationToken); - } - - private static TResponse CreateValidationErrorResult(List failures) - { - var responseType = typeof(TResponse); - - if (responseType.IsGenericType && responseType.GetGenericTypeDefinition() == typeof(Result<>)) - { - var valueType = responseType.GetGenericArguments()[0]; - var error = Error.Validation( - "Validation.Failed", - string.Join("; ", failures.Select(f => $"{f.PropertyName}: {f.ErrorMessage}"))); - - var resultType = typeof(Result<>).MakeGenericType(valueType); - var failureMethod = resultType.GetMethod("Failure", new[] { typeof(Error) }); - - return (TResponse)failureMethod!.Invoke(null, new object[] { error })!; - } - - throw new ValidationException(failures); - } -} diff --git a/src/Mistruna.Core/Filters/SwaggerDefaultValues.cs b/src/Mistruna.Core/Filters/SwaggerDefaultValues.cs deleted file mode 100644 index c1bcd1f..0000000 --- a/src/Mistruna.Core/Filters/SwaggerDefaultValues.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Mistruna.Core.Filters; - -/// -/// Represents the OpenAPI/Swashbuckle operation filter used to document information provided, but not used. -/// -/// This is only required due to bugs in the . -/// Once they are fixed and published, this class can be removed. -public class SwaggerDefaultValues : IOperationFilter -{ - /// - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - var apiDescription = context.ApiDescription; - - operation.Deprecated |= apiDescription.IsDeprecated(); - - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 - foreach (var responseType in context.ApiDescription.SupportedResponseTypes) - { - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/ - // blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/ - // SwaggerGenerator/SwaggerGenerator.cs#L383-L387 - var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); - var response = operation.Responses[responseKey]; - - foreach (var contentType in response.Content.Keys) - { - if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) - { - response.Content.Remove(contentType); - } - } - } - - if (operation.Parameters == null) - { - return; - } - - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 - foreach (var parameter in operation.Parameters) - { - var description = apiDescription.ParameterDescriptions - .First(p => p.Name == parameter.Name); - - parameter.Description ??= description.ModelMetadata?.Description; - } - } -} diff --git a/src/Mistruna.Core/HealthChecks/DatabaseHealthCheck.cs b/src/Mistruna.Core/HealthChecks/DatabaseHealthCheck.cs deleted file mode 100644 index 1959765..0000000 --- a/src/Mistruna.Core/HealthChecks/DatabaseHealthCheck.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; - -namespace Mistruna.Core.HealthChecks; - -/// -/// Generic health check to verify EF Core database connectivity. -/// -/// -/// Initializes a new instance of the class. -/// -/// The database context. -/// The logger instance. -public sealed class DatabaseHealthCheck( - TDbContext dbContext, - ILogger> logger) : IHealthCheck - where TDbContext : DbContext -{ - /// - /// Performs the health check by attempting to connect to the database. - /// - /// The health check context. - /// Cancellation token. - /// The health check result. - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - try - { - var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); - - if (canConnect) - { - logger.LogDebug("Database health check succeeded"); - return HealthCheckResult.Healthy("Database is accessible"); - } - - logger.LogWarning("Database health check failed: Cannot connect"); - return HealthCheckResult.Unhealthy("Cannot connect to database"); - } - catch (Exception ex) - { - logger.LogError(ex, "Database health check failed with exception"); - return HealthCheckResult.Unhealthy("Database health check failed", ex); - } - } -} diff --git a/src/Mistruna.Core/Helpers/ExpressionHelpers.cs b/src/Mistruna.Core/Helpers/ExpressionHelpers.cs index d32b75c..282b5a7 100644 --- a/src/Mistruna.Core/Helpers/ExpressionHelpers.cs +++ b/src/Mistruna.Core/Helpers/ExpressionHelpers.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; namespace Mistruna.Core.Helpers; diff --git a/src/Mistruna.Core/Microservices/Core/Configurations/ApiVersioningConfiguration.cs b/src/Mistruna.Core/Microservices/Core/Configurations/ApiVersioningConfiguration.cs deleted file mode 100644 index 589ba24..0000000 --- a/src/Mistruna.Core/Microservices/Core/Configurations/ApiVersioningConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Asp.Versioning; -using Microsoft.Extensions.DependencyInjection; - -namespace Mistruna.Core.Microservices.Core.Configurations; - -public static class ApiVersioningConfiguration -{ - public static void ConfigureApiVersioning(this IServiceCollection services) - { - services.AddApiVersioning(options => - { - options.ReportApiVersions = true; - options.DefaultApiVersion = new ApiVersion(1.0); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ApiVersionReader = ApiVersionReader.Combine(new HeaderApiVersionReader("apiVersion")); - - options.Policies.Sunset(0.9) - .Effective(DateTimeOffset.Now.AddDays(60)) - .Link("policy.html") - .Title("Versioning Policy") - .Type("text/html"); - }).AddMvc().AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - }); - } -} diff --git a/src/Mistruna.Core/Microservices/Core/Configurations/CorsConfiguration.cs b/src/Mistruna.Core/Microservices/Core/Configurations/CorsConfiguration.cs deleted file mode 100644 index dcdfddd..0000000 --- a/src/Mistruna.Core/Microservices/Core/Configurations/CorsConfiguration.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace Mistruna.Core.Microservices.Core.Configurations; - -/// -/// CORS policy configuration options. -/// This class defines the settings for configuring Cross-Origin Resource Sharing (CORS) policies in ASP.NET Core applications. -/// -public class CorsPolicyOptions -{ - /// - /// Gets or sets a value indicating whether any origin is allowed. - /// When set to true, the CORS policy allows requests from any origin. - /// When set to false, only origins specified in are permitted. - /// - /// - /// Default value is true for development convenience, but should be set to false in production with specific allowed origins. - /// - public bool AllowAnyOrigin { get; set; } = true; - - /// - /// Gets or sets a value indicating whether any HTTP method is allowed. - /// When set to true, the CORS policy allows all HTTP methods (GET, POST, PUT, DELETE, etc.). - /// When set to false, only methods specified in are permitted. - /// - /// - /// Default value is true. For security, consider restricting to specific methods in production. - /// - public bool AllowAnyMethod { get; set; } = true; - - /// - /// Gets or sets a value indicating whether any header is allowed. - /// When set to true, the CORS policy allows all request headers. - /// When set to false, only headers specified in are permitted. - /// - /// - /// Default value is true. Common practice is to allow specific headers like "Content-Type", "Authorization", etc. - /// - public bool AllowAnyHeader { get; set; } = true; - - /// - /// Gets or sets a value indicating whether credentials (cookies, authorization headers) are allowed. - /// When set to true, the CORS policy allows credentials to be included in cross-origin requests. - /// - /// - /// This should only be enabled when necessary, as it reduces security. When enabled, should be false. - /// - public bool AllowCredentials { get; set; } - - /// - /// Gets or sets the list of allowed origins. - /// This property is used when is false. - /// - /// - /// - /// AllowedOrigins = new List<string> { "https://example.com", "https://api.example.com" } - /// - /// - public List? AllowedOrigins { get; set; } - - /// - /// Gets or sets the list of allowed HTTP methods. - /// This property is used when is false. - /// - /// - /// - /// AllowedMethods = new List<string> { "GET", "POST", "PUT" } - /// - /// - public List? AllowedMethods { get; set; } - - /// - /// Gets or sets the list of allowed headers. - /// This property is used when is false. - /// - /// - /// - /// AllowedHeaders = new List<string> { "Content-Type", "Authorization", "X-Custom-Header" } - /// - /// - public List? AllowedHeaders { get; set; } -} diff --git a/src/Mistruna.Core/Microservices/Core/Infrastructure/Authorization/AuthOptions.cs b/src/Mistruna.Core/Microservices/Core/Infrastructure/Authorization/AuthOptions.cs deleted file mode 100644 index 6011005..0000000 --- a/src/Mistruna.Core/Microservices/Core/Infrastructure/Authorization/AuthOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Mistruna.Core.Microservices.Core.Infrastructure.Authorization; - -public static class AuthOptions -{ - public const string JwtIssuer = "Mistruna"; - public const string JwtKey = "E24D397D-DF06-4943-801A-0CEF7C7F1A53"; - public const string JwtKeyId = "mistruna-signing-key"; -} diff --git a/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtAuthorizationOptions.cs b/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtAuthorizationOptions.cs deleted file mode 100644 index 7f61d79..0000000 --- a/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtAuthorizationOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Mistruna.Core.Microservices.Core.JwtAuth; - -public sealed class JwtAuthorizationOptions -{ - public string Issuer { get; set; } = "Mistruna"; - - public string Audience { get; set; } = "Mistruna"; - - public string? Key { get; set; } - - public string? KeyId { get; set; } = "mistruna-signing-key"; - - public string RoleClaimType { get; set; } = "role"; - - public bool AllowToolingFallbackKey { get; set; } - - public string ToolingFallbackKey { get; set; } = "00000000-0000-0000-0000-000000000000"; -} diff --git a/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtTokenProvider.cs b/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtTokenProvider.cs deleted file mode 100644 index fa7717c..0000000 --- a/src/Mistruna.Core/Microservices/Core/JwtAuth/JwtTokenProvider.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Microsoft.IdentityModel.Tokens; -using Mistruna.Core.Microservices.Core.Infrastructure.Authorization; - -namespace Mistruna.Core.Microservices.Core.JwtAuth; - -/// -/// Generates signed JWT tokens from arbitrary claims. -/// Each microservice builds its own claims list from its domain entities -/// and passes them here — this class has no domain coupling. -/// -public static class JwtTokenProvider -{ - /// - /// Generates a signed JWT token for the given claims. - /// Uses , , - /// and from static configuration. - /// - /// Domain-specific claims to embed in the token. - /// Token expiry (default: 30 days from now UTC). - public static string GenerateToken(IEnumerable claims, DateTime? expiry = null) - { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(AuthOptions.JwtKey)) - { - KeyId = AuthOptions.JwtKeyId - }; - - var allClaims = claims - .Append(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())) - .Append(new Claim(JwtRegisteredClaimNames.Iat, - DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); - - var token = new JwtSecurityToken( - issuer: AuthOptions.JwtIssuer, - audience: AuthOptions.JwtIssuer, - claims: allClaims, - notBefore: null, - expires: expiry ?? DateTime.UtcNow.AddDays(30), - signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); - - return new JwtSecurityTokenHandler().WriteToken(token); - } - - /// - /// Generates a system-level service-to-service token. - /// - public static string GenerateSystemToken() - { - var claims = new[] - { - new Claim("UserId", Guid.Empty.ToString()), - new Claim("Username", "system"), - new Claim("Role", "System") - }; - - return GenerateToken(claims, DateTime.UtcNow.AddDays(365)); - } -} diff --git a/src/Mistruna.Core/Microservices/Core/JwtAuth/Policies/Policies.cs b/src/Mistruna.Core/Microservices/Core/JwtAuth/Policies/Policies.cs deleted file mode 100644 index 3649105..0000000 --- a/src/Mistruna.Core/Microservices/Core/JwtAuth/Policies/Policies.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Mistruna.Core.Microservices.Core.JwtAuth.Policies; - -public static class Policies -{ - public const string ModeratorOnly = nameof(ModeratorOnly); - public const string MemberOnly = nameof(MemberOnly); - public const string AdminOnly = nameof(AdminOnly); -} diff --git a/src/Mistruna.Core/Microservices/Core/JwtAuth/RoleType.cs b/src/Mistruna.Core/Microservices/Core/JwtAuth/RoleType.cs deleted file mode 100644 index 6174fe1..0000000 --- a/src/Mistruna.Core/Microservices/Core/JwtAuth/RoleType.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Mistruna.Core.Microservices.Core.JwtAuth; - -/// -/// Role name constants used in JWT claims and authorization policies. -/// -public static class RoleType -{ - public const string Member = nameof(Member); - public const string Moderator = nameof(Moderator); - public const string Administrator = nameof(Administrator); -} diff --git a/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceCollectionExtension.cs b/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceCollectionExtension.cs deleted file mode 100644 index 5f6bc2b..0000000 --- a/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceCollectionExtension.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi; -using Mistruna.Core.Microservices.Core.Infrastructure.Authorization; -using Mistruna.Core.Microservices.Core.JwtAuth; -using Mistruna.Core.Microservices.Core.JwtAuth.Policies; -using Swashbuckle.AspNetCore.Filters; - -namespace Mistruna.Core.Microservices.Core.ServerMiddleware; - -public static class ServiceCollectionExtension -{ - public static IServiceCollection AddCustomAuthorization(this IServiceCollection services) - { - return AddJwtAuthorizationCore(services, new JwtAuthorizationOptions - { - Issuer = AuthOptions.JwtIssuer, - Audience = AuthOptions.JwtIssuer, - Key = AuthOptions.JwtKey, - KeyId = AuthOptions.JwtKeyId - }); - } - - public static IServiceCollection AddJwtAuthorization( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null) - { - var options = new JwtAuthorizationOptions - { - Issuer = configuration["Jwt:Issuer"] ?? "Mistruna", - Audience = configuration["Jwt:Audience"] ?? configuration["Jwt:Issuer"] ?? "Mistruna", - Key = configuration["Jwt:Key"] ?? Environment.GetEnvironmentVariable("KEY"), - KeyId = configuration["Jwt:KeyId"] ?? "mistruna-signing-key" - }; - - configure?.Invoke(options); - - if (string.IsNullOrWhiteSpace(options.Key) && options.AllowToolingFallbackKey) - { - options.Key = options.ToolingFallbackKey; - } - - if (string.IsNullOrWhiteSpace(options.Key)) - { - throw new InvalidOperationException( - "JWT signing key is not configured. Set 'Jwt:Key' in configuration or 'KEY' environment variable."); - } - - return AddJwtAuthorizationCore(services, options); - } - - private static IServiceCollection AddJwtAuthorizationCore( - IServiceCollection services, - JwtAuthorizationOptions options) - { - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); - - services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(cfg => - { - cfg.RequireHttpsMetadata = false; - cfg.SaveToken = true; - var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Key!)) - { - KeyId = options.KeyId - }; - - cfg.TokenValidationParameters = new TokenValidationParameters - { - ValidIssuer = options.Issuer, - ValidAudience = options.Audience, - IssuerSigningKey = signingKey, - ClockSkew = TimeSpan.Zero, - RoleClaimType = options.RoleClaimType, - TryAllIssuerSigningKeys = true - }; - }); - - services.AddAuthorization(options => - { - options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(RoleType.Administrator)); - }); - - return services; - } - - public static IServiceCollection AddSwaggerConfiguration(this IServiceCollection collection, string assemblyName) - { - collection.AddSwaggerGen(c => - { - c.SwaggerDoc("v3", new OpenApiInfo - { - Title = assemblyName + " API", - Version = "v3" - }); - var baseDirectory = AppContext.BaseDirectory; - c.IncludeXmlComments(baseDirectory + assemblyName + ".xml"); - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Description = "JWT Authorization header using the Bearer scheme. " + - "Example: \"Authorization: Bearer {token}\"", - Type = SecuritySchemeType.Http, - Scheme = "bearer", - BearerFormat = "JWT" - }); - c.OperationFilter(); - }); - return collection; - } -} diff --git a/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceMiddleware.cs b/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceMiddleware.cs deleted file mode 100644 index 5406eff..0000000 --- a/src/Mistruna.Core/Microservices/Core/ServerMiddleware/ServiceMiddleware.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Mistruna.Core.Microservices.Core.JwtAuth; -using Newtonsoft.Json; - -namespace Mistruna.Core.Microservices.Core.ServerMiddleware; - -/// -/// Utility helpers for configuring and working with ASP.NET Core microservice servers. -/// -public static class ServiceMiddleware -{ - /// - /// Registers controllers with Newtonsoft.Json serialization configured for indented ISO dates - /// and camelCase naming, which is the expected format for inter-service communication. - /// - /// The service collection. - public static void AddServerControllers(this IServiceCollection services) - { - services.AddHttpContextAccessor(); - services.AddControllers().AddNewtonsoftJson((Action)(opt => - { - opt.SerializerSettings.Formatting = Formatting.Indented; - opt.SerializerSettings.DateFormatHandling = DateFormatHandling.IsoDateFormat; - opt.SerializerSettings.DateParseHandling = DateParseHandling.DateTime; - opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - opt.UseCamelCasing(true); - })); - } - - /// - /// Extracts the current user's ID from the "UserId" claim. - /// - /// The claims principal to read from. - /// The parsed user ID as . - /// Thrown when the UserId claim is missing. - public static Guid GetUserId(this IEnumerable claims) - => Guid.Parse((claims.SingleOrDefault(s => s.Type == "UserId") - ?? throw new UnauthorizedAccessException("UserId claim is missing.")).Value); - - /// - /// Checks whether the current controller user is in the Administrator role. - /// - /// The controller base instance. - /// True if the user is a root administrator. - public static bool IsInRootAdmin(this ControllerBase controller) - => controller.User.IsInRole(RoleType.Administrator); - - /// - /// Reads the configured host address for a named microservice from the "ServicesHosts" configuration section. - /// - /// The application configuration. - /// The service name key. - /// The host URL, or null if not configured. - public static string GetMicroserviceHost(this IConfiguration configuration, string name) - => configuration?.GetSection("ServicesHosts")?[name]; -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Attributes/RabbitQueryAttribute.cs b/src/Mistruna.Core/Microservices/RabbitMq/Attributes/RabbitQueryAttribute.cs deleted file mode 100644 index dba8846..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Attributes/RabbitQueryAttribute.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Mistruna.Core.Microservices.RabbitMq.Attributes; - -/// -/// Marks a message type for RabbitMQ routing, specifying the target exchange and routing key. -/// Used by and . -/// -public class RabbitQueryAttribute : Attribute -{ - /// The exchange name to publish to or consume from. - public string ExchangeName { get; set; } - - /// The exchange type (e.g., "direct", "topic", "fanout"). - public string ExchangeType { get; set; } - - /// The routing key for message delivery. - public string RouteKey { get; set; } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Configurations/RabbitMqConfiguration.cs b/src/Mistruna.Core/Microservices/RabbitMq/Configurations/RabbitMqConfiguration.cs deleted file mode 100644 index 8aa02cb..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Configurations/RabbitMqConfiguration.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Mistruna.Core.Microservices.RabbitMq.Configurations; - -/// -/// Configuration options for connecting to a RabbitMQ broker. -/// -public class RabbitMqConfiguration -{ - /// RabbitMQ server hostname. - public string Hostname { get; init; } - - /// Default queue name. - public string QueueName { get; init; } - - /// Authentication username. - public string UserName { get; init; } - - /// Authentication password. - public string Password { get; init; } - - /// RabbitMQ server port. Use -1 for the default AMQP port (5672). - public int Port { get; init; } - - /// Virtual host name. - public string VHost { get; init; } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Extensions/RabbitServiceCollectionExtensions.cs b/src/Mistruna.Core/Microservices/RabbitMq/Extensions/RabbitServiceCollectionExtensions.cs deleted file mode 100644 index 064d89f..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Extensions/RabbitServiceCollectionExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.ObjectPool; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Configurations; -using Mistruna.Core.Microservices.RabbitMq.Services.Implementations; -using Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; -using RabbitMQ.Client; - -namespace Mistruna.Core.Microservices.RabbitMq.Extensions; - -/// -/// DI registration helpers for RabbitMQ messaging infrastructure. -/// -public static class RabbitServiceCollectionExtensions -{ - /// - /// Registers the RabbitMQ connection, channel pool, and implementation. - /// - /// The service collection. - /// The application configuration (expects a "RabbitMq" section). - /// The service collection. - public static IServiceCollection AddRabbit(this IServiceCollection services, IConfiguration configuration) - { - services.Configure(configuration.GetSection("RabbitMq")); - services.AddSingleton(); - services.AddSingleton, RabbitModelPooledObjectPolicy>(); - services.AddSingleton(); - - return services; - } - - /// - /// Registers message bus endpoints and the hosted service that starts consuming messages. - /// - /// The service collection. - /// Action to configure the endpoints collection. - public static void AddRabbitMqEndpoints( - this IServiceCollection services, - Action configuration) - { - var implementationInstance = new EndpointsConfiguration(); - configuration(implementationInstance); - services.AddSingleton((IEndpointsConfiguration) implementationInstance); - services.AddHostedService(); - } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/RabbitMqEndpointBinder/RabbitMqWrapper.cs b/src/Mistruna.Core/Microservices/RabbitMq/RabbitMqEndpointBinder/RabbitMqWrapper.cs deleted file mode 100644 index 0d4faa1..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/RabbitMqEndpointBinder/RabbitMqWrapper.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Text; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Configurations; -using Mistruna.Core.Microservices.RabbitMq.Services.Implementations; -using Newtonsoft.Json; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; - -namespace Mistruna.Core.Microservices.RabbitMq.RabbitMqEndpointBinder; - -/// -/// Wraps a RabbitMQ queue consumer that dispatches incoming messages through MediatR. -/// Each message is handled within a scoped service provider to support DI in handlers. -/// -public class RabbitMqWrapper : IBus -{ - private readonly IModel _channel; - private readonly IConnection _connection; - private readonly string _queue; - private readonly IServiceProvider _serviceProvider; - - public RabbitMqWrapper( - IOptions options, - EndpointConfiguration configuration, - IServiceProvider services) - { - _serviceProvider = services; - _connection = new ConnectionFactory - { - HostName = options.Value.Hostname, - UserName = options.Value.UserName, - Password = options.Value.Password, - Port = -1, - VirtualHost = options.Value.VHost - }.CreateConnection(); - _channel = _connection.CreateModel(); - _queue = configuration.Queue; - _channel.QueueDeclare(_queue, configuration.Durable, configuration.Exclusive, configuration.AutoDelete, - configuration.Arguments); - _channel.QueueBind(configuration.Queue, configuration.Exchange, configuration.RoutingKey); - } - - public Task ExecuteAsync(CancellationToken stoppingToken) - { - var consumer = new EventingBasicConsumer(_channel); - consumer.Received += (EventHandler)(async (_, ea) => - { - var content = Encoding.UTF8.GetString(ea.Body.ToArray()); - try - { - using var scope = _serviceProvider.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - var message = JsonConvert.DeserializeObject(content); - _ = await mediator.Send((object)message, stoppingToken); - } - finally - { - _channel.BasicAck(ea.DeliveryTag, false); - } - }); - _channel.BasicConsume(_queue, false, (IBasicConsumer)consumer); - - return Task.CompletedTask; - } - - public void Dispose() - { - _channel.Close(); - _connection.Close(); - } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointConfiguration.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointConfiguration.cs deleted file mode 100644 index c19ff7e..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointConfiguration.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.Extensions.Options; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Configurations; -using Mistruna.Core.Microservices.RabbitMq.RabbitMqEndpointBinder; -using Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Implementations; - -/// -/// Generic endpoint configuration that binds a message type to a RabbitMQ queue. -/// -/// The message type handled by this endpoint. -public class EndpointConfiguration : IEndpointConfiguration -{ - public string Queue { get; init; } - - public bool Durable { get; init; } - - public bool Exclusive { get; init; } - - public bool AutoDelete { get; init; } - - public IDictionary Arguments { get; init; } - - public string Exchange { get; private set; } - - public string RoutingKey { get; private set; } - - public void WithBinding(string exchange, string routingKey) - { - Exchange = exchange; - RoutingKey = routingKey; - } - - public IBus BuildWrapper(IServiceProvider services, IOptions options) - => new RabbitMqWrapper(options, this, services); -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointsConfiguration.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointsConfiguration.cs deleted file mode 100644 index b11dd44..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/EndpointsConfiguration.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Implementations; - -/// -/// Default implementation of that collects endpoint definitions. -/// -public class EndpointsConfiguration : IEndpointsConfiguration -{ - /// - /// Maps a message type to a RabbitMQ queue with optional binding configuration. - /// - /// The message type handled by this endpoint. - /// The queue name. - /// Whether the queue survives broker restarts. - /// Whether the queue is limited to this connection. - /// Whether the queue is deleted when the last consumer unsubscribes. - /// Optional RabbitMQ arguments (e.g., dead-letter exchange). - /// The created endpoint configuration for further chaining. - public IEndpointConfiguration MapEndpoint( - string queue, - bool durable = true, - bool exclusive = false, - bool autoDelete = false, - IDictionary arguments = null) - { - var endpointConfiguration = new EndpointConfiguration - { - Queue = queue, - Durable = durable, - Exclusive = exclusive, - AutoDelete = autoDelete, - Arguments = arguments - }; - Endpoints.Add(endpointConfiguration); - - return endpointConfiguration; - } - - public List Endpoints { get; set; } = []; -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitMQHostedService.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitMQHostedService.cs deleted file mode 100644 index 09d6fce..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitMQHostedService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Configurations; -using Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Implementations; - -/// -/// Background service that starts all configured RabbitMQ message bus consumers on application startup. -/// -public class RabbitMqHostedService : BackgroundService -{ - private readonly IList _wrappers; - - public RabbitMqHostedService( - IServiceProvider services, - IOptions options, - IEndpointsConfiguration configuration) - { - _wrappers = (IList)configuration.Endpoints - .Select( - (Func) - (x => x.BuildWrapper(services, options))) - .ToList(); - } - - protected override Task ExecuteAsync(CancellationToken stoppingToken) - => Task.WhenAll(_wrappers.Select((Func)(x => x.ExecuteAsync(stoppingToken)))); -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitManager.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitManager.cs deleted file mode 100644 index a1c8e9d..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitManager.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using System.Text; -using Microsoft.Extensions.ObjectPool; -using Mistruna.Core.Contracts.Base.Infrastructure; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Attributes; -using Newtonsoft.Json; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Implementations; - -/// -/// Manages RabbitMQ operations: publishing, subscribing, and sending messages. -/// Uses an object pool for channels to reduce allocation overhead. -/// -public class RabbitManager(IPooledObjectPolicy objectPolicy) : IRabbitManager -{ - private readonly DefaultObjectPool _objectPool = new(objectPolicy, Environment.ProcessorCount * 2); - - /// - /// Publishes a message to a RabbitMQ exchange with the specified routing key. - /// - public void Publish(T message, string exchangeName, string exchangeType, string routeKey) where T : class - { - if (message == null) - { - return; - } - - var model = _objectPool.Get(); - try - { - model.ExchangeDeclare(exchangeName, exchangeType, true, false, null); - var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)); - var basicProperties = model.CreateBasicProperties(); - basicProperties.Persistent = true; - basicProperties.Type = message.GetType().Name; - model.BasicPublish(exchangeName, routeKey, basicProperties, (ReadOnlyMemory)bytes); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to publish message to exchange '{exchangeName}'.", ex); - } - finally - { - _objectPool.Return(model); - } - } - - public T Subscribe(string queueName, Guid id) where T : IEntity - { - var respQueue = new BlockingCollection(); - var channel = _objectPool.Get(); - channel.QueueDeclare(queueName, true, false, false, null); - var consumer = new EventingBasicConsumer(channel); - consumer.Received += (EventHandler)((ch, ea) => - { - var obj = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(ea.Body.ToArray())); - channel.BasicAck(ea.DeliveryTag, false); - if (obj.Id != id) - { - return; - } - - respQueue.Add(obj); - respQueue.CompleteAdding(); - }); - channel.BasicConsume(queueName, false, consumer); - return respQueue.Take(); - } - - /// - /// Sends a message to the exchange and routing key specified by the - /// on the message type. - /// - public void Send(T message) where T : class - { - if (message == null) - { - return; - } - - var model = _objectPool.Get(); - if (typeof(T).GetCustomAttributes(typeof(RabbitQueryAttribute)).FirstOrDefault() - is not RabbitQueryAttribute rabbitQueryAttribute) - { - throw new InvalidOperationException($"The RabbitQueryAttribute does not exist on type {typeof(T).Name}."); - } - - try - { - model.ExchangeDeclare( - rabbitQueryAttribute.ExchangeName, - rabbitQueryAttribute.ExchangeType, - true, - false, - null); - var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)); - var basicProperties = model.CreateBasicProperties(); - basicProperties.Persistent = true; - model.BasicPublish( - rabbitQueryAttribute.ExchangeName, - rabbitQueryAttribute.RouteKey, - basicProperties, - (ReadOnlyMemory)bytes); - } - finally - { - _objectPool.Return(model); - } - } - - /// - /// Registers a consumer that deserializes incoming messages and invokes a handler function. - /// The handler return value is discarded; this method is intended for fire-and-forget processing. - /// - public void Consume(Func lambda) - { - if (typeof(T).GetCustomAttributes(typeof(RabbitQueryAttribute)).FirstOrDefault() - is not RabbitQueryAttribute) - { - throw new InvalidOperationException($"The RabbitQueryAttribute does not exist on type {typeof(T).Name}."); - } - - var channel = _objectPool.Get(); - channel.QueueDeclare( - Assembly.GetExecutingAssembly().FullName + nameof(T), - true, - false, - false, - null); - new EventingBasicConsumer(channel).Received += (EventHandler)(async (ch, ea) => - { - var content = Encoding.UTF8.GetString(ea.Body.ToArray()); - try - { - var message = JsonConvert.DeserializeObject(content); - _ = lambda(message); - } - finally - { - channel.BasicAck(ea.DeliveryTag, false); - } - }); - } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitModelPooledObjectPolicy.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitModelPooledObjectPolicy.cs deleted file mode 100644 index 3732343..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Implementations/RabbitModelPooledObjectPolicy.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.ObjectPool; -using Microsoft.Extensions.Options; -using Mistruna.Core.Microservices.RabbitMq.Configurations; -using RabbitMQ.Client; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Implementations; - -/// -/// Object pool policy that creates and manages (RabbitMQ channel) instances. -/// Reuses connections to avoid the overhead of establishing a new TCP connection per operation. -/// -public class RabbitModelPooledObjectPolicy : IPooledObjectPolicy -{ - private readonly RabbitMqConfiguration _options; - private readonly IConnection _connection; - - public RabbitModelPooledObjectPolicy(IOptions options) - { - _options = options.Value; - _connection = GetConnection(); - } - - private IConnection GetConnection() - { - return new ConnectionFactory - { - HostName = _options.Hostname, - UserName = _options.UserName, - Password = _options.Password, - Port = -1, - VirtualHost = _options.VHost - }.CreateConnection(); - } - - public IModel Create() => _connection.CreateModel(); - - public bool Return(IModel obj) - { - if (obj.IsOpen) - { - return true; - } - - obj.Dispose(); - - return false; - } -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointConfiguration.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointConfiguration.cs deleted file mode 100644 index a5d071d..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointConfiguration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Options; -using Mistruna.Core.Contracts.Microservices.RabbitMq.Services.Interfaces; -using Mistruna.Core.Microservices.RabbitMq.Configurations; - -namespace Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; - -/// -/// Configures a single RabbitMQ endpoint (queue + exchange binding). -/// -public interface IEndpointConfiguration -{ - /// - /// Binds the queue to an exchange with the given routing key. - /// - void WithBinding(string exchange, string routingKey); - - /// - /// Builds the message bus wrapper that handles consuming messages from this endpoint. - /// - IBus BuildWrapper(IServiceProvider services, IOptions options); -} diff --git a/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointsConfiguration.cs b/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointsConfiguration.cs deleted file mode 100644 index 02b1312..0000000 --- a/src/Mistruna.Core/Microservices/RabbitMq/Services/Interfaces/IEndpointsConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Mistruna.Core.Microservices.RabbitMq.Services.Interfaces; - -/// -/// Aggregates multiple instances for batch registration. -/// -public interface IEndpointsConfiguration -{ - /// Collection of configured endpoints. - List Endpoints { get; set; } -} diff --git a/src/Mistruna.Core/Middlewares/ExceptionHandlingMiddleware.cs b/src/Mistruna.Core/Middlewares/ExceptionHandlingMiddleware.cs deleted file mode 100644 index 5899e7c..0000000 --- a/src/Mistruna.Core/Middlewares/ExceptionHandlingMiddleware.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Net.Mime; -using FluentValidation; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Mistruna.Core.Contracts.Base.Responses; -using Mistruna.Core.Exceptions; - -namespace Mistruna.Core.Middlewares; - -/// -/// Middleware that provides centralized exception handling for ASP.NET Core applications. -/// Converts exceptions to appropriate HTTP responses with consistent error format. -/// -/// -/// This middleware catches exceptions thrown by downstream middleware and converts them -/// to appropriate HTTP responses. It supports the following exception types: -/// -/// - Returns HTTP 400 Bad Request -/// - Returns HTTP 400 Bad Request -/// - Returns HTTP 404 Not Found -/// - Returns HTTP 401 Unauthorized -/// - Returns HTTP 403 Forbidden -/// - Returns HTTP 408 Request Timeout -/// - Returns HTTP 409 Conflict -/// - Returns HTTP 501 Not Implemented -/// - Returns HTTP 500 Internal Server Error -/// -/// -/// Register this middleware in your request pipeline: -/// -/// app.UseMiddleware<ExceptionHandlingMiddleware>(); -/// -/// -public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) -{ - /// - /// Invokes the middleware, catching and handling any exceptions that occur. - /// - /// The HTTP context for the current request. - /// A task representing the asynchronous operation. - public async Task InvokeAsync(HttpContext context) - { - try - { - await next(context); - } - catch (ValidationException ex) - { - var errors = ex.Errors.Select(e => new { e.PropertyName, e.ErrorMessage, e.ErrorCode }); - await HandleExceptionAsync(context, StatusCodes.Status400BadRequest, "Validation error.", errors, "VALIDATION_ERROR"); - } - catch (BadRequestException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status400BadRequest, ex.Message, ex.Details, ex.ErrorCode); - } - catch (NotFoundException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status404NotFound, ex.Message, ex.Details, ex.ErrorCode); - } - catch (UnauthorizedAccessException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status401Unauthorized, "Unauthorized.", ex.Message, "UNAUTHORIZED"); - } - catch (ForbiddenAccessException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status403Forbidden, ex.Message, ex.Details, ex.ErrorCode); - } - catch (TimeoutException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status408RequestTimeout, "Request timed out.", ex.Message, "TIMEOUT"); - } - catch (DbUpdateException ex) - when (IsForeignKeyViolationExceptionMiddleware.CheckForeignKeyViolation(ex, out var referencedObject)) - { - await HandleExceptionAsync( - context, - StatusCodes.Status409Conflict, - "Unable to delete object. It is referenced by another entity.", - referencedObject, - "FOREIGN_KEY_VIOLATION"); - } - catch (DbUpdateException ex) - when (IsUniqueConstraintViolationExceptionMiddleware.CheckUniqueConstraintViolation(ex, out var violatedConstraint)) - { - logger.LogWarning(ex, "Unique constraint violation: {ConstraintName}", violatedConstraint); - await HandleExceptionAsync( - context, - StatusCodes.Status409Conflict, - "The record with the provided values already exists.", - violatedConstraint, - "UNIQUE_CONSTRAINT_VIOLATION"); - } - catch (DbUpdateException ex) - { - logger.LogError(ex, "Database error occurred."); - await HandleExceptionAsync( - context, - StatusCodes.Status500InternalServerError, - "Database error.", - ex.InnerException?.Message ?? ex.Message, - "DATABASE_ERROR"); - } - catch (OperationCanceledException) - { - context.Response.StatusCode = 499; - } - catch (ConflictException ex) - { - await HandleExceptionAsync(context, StatusCodes.Status409Conflict, ex.Message, ex.Details, ex.ErrorCode); - } - catch (NotImplementedException) - { - await HandleExceptionAsync(context, StatusCodes.Status501NotImplemented, "Not implemented.", errorCode: "NOT_IMPLEMENTED"); - } - catch (CoreException ex) - { - logger.LogWarning(ex, "Core exception occurred: {ErrorCode}", ex.ErrorCode); - await HandleExceptionAsync(context, StatusCodes.Status400BadRequest, ex.Message, ex.Details, ex.ErrorCode); - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error occurred."); - await HandleExceptionAsync(context, StatusCodes.Status500InternalServerError, - "An unexpected error occurred.", errorCode: "INTERNAL_ERROR"); - } - } - - private static Task HandleExceptionAsync( - HttpContext context, - int statusCode, - string message, - object? details = null, - string? errorCode = null) - { - context.Response.ContentType = MediaTypeNames.Application.Json; - context.Response.StatusCode = statusCode; - - var response = new ErrorResponse - { - Message = message, - ErrorCode = errorCode, - Details = details, - TraceId = context.TraceIdentifier, - Timestamp = DateTime.UtcNow - }; - - return context.Response.WriteAsJsonAsync(response); - } -} diff --git a/src/Mistruna.Core/Middlewares/IsForeignKeyViolationExceptionMiddleware.cs b/src/Mistruna.Core/Middlewares/IsForeignKeyViolationExceptionMiddleware.cs deleted file mode 100644 index ede7d12..0000000 --- a/src/Mistruna.Core/Middlewares/IsForeignKeyViolationExceptionMiddleware.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Mistruna.Core.Middlewares; - -/// -/// Helper middleware for detecting foreign-key constraint violations. -/// -/// -/// Detects foreign-key violations by inspecting message. -/// Covers SQL Server ("FOREIGN KEY constraint"), PostgreSQL ("violates foreign key constraint"), -/// and SQLite ("FOREIGN KEY constraint failed") without requiring provider-specific assemblies. -/// -public static class IsForeignKeyViolationExceptionMiddleware -{ - /// - /// Checks if a DbUpdateException is caused by a foreign-key constraint violation. - /// - /// The DbUpdateException to check. - /// The referenced object name if extractable from the message. - /// True if a foreign-key violation was detected, false otherwise. - public static bool CheckForeignKeyViolation(DbUpdateException ex, out string referencedObject) - { - referencedObject = null; - - if (!IsForeignKeyViolation(ex)) - { - return false; - } - - var errorMessage = ex.InnerException?.Message ?? string.Empty; - - var startIdx = errorMessage.IndexOf("referenced by '", StringComparison.Ordinal); - if (startIdx != -1) - { - startIdx += "referenced by '".Length; - var endIdx = errorMessage.IndexOf("'", startIdx, StringComparison.Ordinal); - if (endIdx != -1) - { - referencedObject = errorMessage.Substring(startIdx, endIdx - startIdx); - } - } - - return true; - } - - private static bool IsForeignKeyViolation(DbUpdateException ex) - { - var inner = ex.InnerException; - if (inner is null) return false; - - var typeName = inner.GetType().Name; - return (typeName == "SqlException" && inner.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)) - || inner.Message.Contains("foreign key constraint", StringComparison.OrdinalIgnoreCase) - || inner.Message.Contains("violates foreign key constraint", StringComparison.OrdinalIgnoreCase) - || inner.Message.Contains("FOREIGN KEY constraint failed", StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/Mistruna.Core/Middlewares/IsUniqueConstraintViolationExceptionMiddleware.cs b/src/Mistruna.Core/Middlewares/IsUniqueConstraintViolationExceptionMiddleware.cs deleted file mode 100644 index 73dc567..0000000 --- a/src/Mistruna.Core/Middlewares/IsUniqueConstraintViolationExceptionMiddleware.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Mistruna.Core.Middlewares; - -/// -/// Helper middleware for detecting unique constraint violations. -/// -/// -/// Detects unique-constraint violations by inspecting message. -/// Covers SQL Server ("UNIQUE KEY constraint", "duplicate key"), PostgreSQL ("unique constraint", "duplicate key value"), -/// and SQLite ("UNIQUE constraint failed") without requiring provider-specific assemblies. -/// -public static class IsUniqueConstraintViolationExceptionMiddleware -{ - /// - /// Checks if a DbUpdateException is caused by a unique constraint violation. - /// - /// The DbUpdateException to check. - /// The name of the violated constraint if found. - /// True if a unique constraint violation was detected, false otherwise. - public static bool CheckUniqueConstraintViolation(DbUpdateException ex, out string violatedConstraint) - { - violatedConstraint = null; - - if (!IsUniqueConstraintViolation(ex)) - return false; - - var errorMessage = ex.InnerException?.Message ?? string.Empty; - - var constraintMatch = errorMessage.IndexOf("constraint '", StringComparison.OrdinalIgnoreCase); - if (constraintMatch != -1) - { - var startIdx = constraintMatch + "constraint '".Length; - var endIdx = errorMessage.IndexOf("'", startIdx, StringComparison.Ordinal); - if (endIdx != -1) - { - violatedConstraint = errorMessage.Substring(startIdx, endIdx - startIdx); - } - } - - return true; - } - - private static bool IsUniqueConstraintViolation(DbUpdateException ex) - { - var inner = ex.InnerException; - if (inner is null) return false; - - var typeName = inner.GetType().Name; - return (typeName == "SqlException" && inner.Message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase)) - || inner.Message.Contains("duplicate key", StringComparison.OrdinalIgnoreCase) - || inner.Message.Contains("unique constraint", StringComparison.OrdinalIgnoreCase) - || inner.Message.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/Mistruna.Core/Mistruna.Core.csproj b/src/Mistruna.Core/Mistruna.Core.csproj index afde98e..5b0b65f 100644 --- a/src/Mistruna.Core/Mistruna.Core.csproj +++ b/src/Mistruna.Core/Mistruna.Core.csproj @@ -5,10 +5,9 @@ 14 Library true - true Mistruna.Core - ASP.NET Core SDK for microservices: MediatR, CQRS, FluentValidation, JWT, RabbitMQ, Redis, health checks, rate limiting, idempotency, and consistent API primitives. + MediatR CQRS host, validation and logging behaviors, and core exceptions for Mistruna. $(NoWarn);CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8625;CS8765;CS8767;CS0659;CS1998;CS1591;CS1574 @@ -20,22 +19,12 @@ - + - - - - - - - - - - - - + + diff --git a/src/Mistruna.Core/RateLimiting/RateLimitOptions.cs b/src/Mistruna.Core/RateLimiting/RateLimitOptions.cs deleted file mode 100644 index 64e1921..0000000 --- a/src/Mistruna.Core/RateLimiting/RateLimitOptions.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Mistruna.Core.RateLimiting; - -/// -/// Configuration options for IP-based rate limiting middleware. -/// -public class RateLimitOptions -{ - /// - /// Gets or sets the maximum number of requests allowed per time window. - /// Defaults to 100. - /// - public int RequestsPerWindow { get; set; } = 100; - - /// - /// Gets or sets the time window duration in seconds. - /// Defaults to 60 seconds. - /// - public int WindowSeconds { get; set; } = 60; - - /// - /// Gets or sets the Redis key prefix for rate limit counters. - /// Defaults to "ratelimit". - /// - public string KeyPrefix { get; set; } = "ratelimit"; - - /// - /// Gets or sets a value indicating whether to include rate limit headers in responses. - /// Defaults to true. - /// - public bool IncludeHeaders { get; set; } = true; -} diff --git a/src/Mistruna.Core/RateLimiting/RateLimitingMiddleware.cs b/src/Mistruna.Core/RateLimiting/RateLimitingMiddleware.cs deleted file mode 100644 index 71708f4..0000000 --- a/src/Mistruna.Core/RateLimiting/RateLimitingMiddleware.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Collections.Concurrent; -using System.Net.Mime; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using StackExchange.Redis; - -namespace Mistruna.Core.RateLimiting; - -/// -/// Middleware that enforces per-IP rate limiting using Redis for distributed counter storage, -/// with an in-memory fallback when Redis is unavailable. -/// Returns HTTP 429 Too Many Requests when the limit is exceeded. -/// -/// -/// Initializes a new instance of the class. -/// -/// The next middleware in the pipeline. -/// Rate limiting configuration options. -/// The logger instance. -/// Optional Redis connection multiplexer. Falls back to in-memory if null. -public class RateLimitingMiddleware( - RequestDelegate next, - RateLimitOptions options, - ILogger logger, - IConnectionMultiplexer? redis = null) -{ - // In-memory fallback when Redis is unavailable - private readonly ConcurrentDictionary _inMemoryCounters = new(); - - /// - /// Processes the HTTP request and enforces rate limiting. - /// - /// The HTTP context for the current request. - public async Task InvokeAsync(HttpContext context) - { - var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - var key = $"{options.KeyPrefix}:{clientIp}"; - var window = TimeSpan.FromSeconds(options.WindowSeconds); - - var (requestCount, remaining, retryAfter) = redis is { IsConnected: true } - ? await CheckRateLimitRedisAsync(key, window) - : CheckRateLimitInMemory(key, window); - - if (options.IncludeHeaders) - { - context.Response.Headers["X-RateLimit-Limit"] = options.RequestsPerWindow.ToString(); - context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, remaining).ToString(); - context.Response.Headers["X-RateLimit-Reset"] = retryAfter.ToString(); - } - - if (requestCount > options.RequestsPerWindow) - { - logger.LogWarning("Rate limit exceeded for {ClientIp}. Count: {Count}/{Limit}", - clientIp, requestCount, options.RequestsPerWindow); - - context.Response.StatusCode = StatusCodes.Status429TooManyRequests; - context.Response.ContentType = MediaTypeNames.Application.Json; - - if (options.IncludeHeaders) - { - context.Response.Headers.RetryAfter = retryAfter.ToString(); - } - - await context.Response.WriteAsJsonAsync(new - { - message = "Too many requests. Please try again later.", - errorCode = "RATE_LIMIT_EXCEEDED", - retryAfterSeconds = retryAfter - }); - - return; - } - - await next(context); - } - - private async Task<(long Count, long Remaining, long RetryAfter)> CheckRateLimitRedisAsync( - string key, TimeSpan window) - { - try - { - var db = redis!.GetDatabase(); - var count = await db.StringIncrementAsync(key); - - if (count == 1) - { - await db.KeyExpireAsync(key, window); - } - - var ttl = await db.KeyTimeToLiveAsync(key); - var retryAfter = (long)(ttl?.TotalSeconds ?? options.WindowSeconds); - var remaining = options.RequestsPerWindow - count; - - return (count, remaining, retryAfter); - } - catch (RedisException ex) - { - logger.LogError(ex, "Redis rate limit check failed, falling back to in-memory"); - return CheckRateLimitInMemory(key, window); - } - } - - private (long Count, long Remaining, long RetryAfter) CheckRateLimitInMemory( - string key, TimeSpan window) - { - var now = DateTime.UtcNow; - - var entry = _inMemoryCounters.AddOrUpdate( - key, - _ => (1, now), - (_, existing) => - { - if (now - existing.WindowStart >= window) - return (1, now); - - return (existing.Count + 1, existing.WindowStart); - }); - - var elapsed = now - entry.WindowStart; - var retryAfter = (long)Math.Max(0, window.TotalSeconds - elapsed.TotalSeconds); - var remaining = options.RequestsPerWindow - entry.Count; - - return (entry.Count, remaining, retryAfter); - } -} diff --git a/test/Mistruna.Core.Tests/Abstractions/RequestKindTests.cs b/test/Mistruna.Core.Tests/Abstractions/RequestKindTests.cs index faf6571..5a958cc 100644 --- a/test/Mistruna.Core.Tests/Abstractions/RequestKindTests.cs +++ b/test/Mistruna.Core.Tests/Abstractions/RequestKindTests.cs @@ -1,4 +1,4 @@ -using Mistruna.Core.Abstractions; +using Mistruna.Core.Abstractions.Cqrs; using Xunit; namespace Mistruna.Core.Tests.Abstractions; diff --git a/test/Mistruna.Core.Tests/AspNetCore/ExceptionHandlerTests.cs b/test/Mistruna.Core.Tests/AspNetCore/ExceptionHandlerTests.cs new file mode 100644 index 0000000..ef8354d --- /dev/null +++ b/test/Mistruna.Core.Tests/AspNetCore/ExceptionHandlerTests.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Mistruna.Core.Samples.BasicApi.Features.Ping; +using Xunit; + +namespace Mistruna.Core.Tests.AspNetCore; + +public sealed class ExceptionHandlerTests + : IClassFixture> +{ + private readonly HttpClient _client; + + public ExceptionHandlerTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task NotFoundException_Returns_404_ProblemDetails_WithErrorCode_AndTraceId() + { + var response = await _client.GetAsync("/errors/not-found"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal("X.NotFound", json.GetProperty("errorCode").GetString()); + Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("traceId").GetString())); + } +} diff --git a/test/Mistruna.Core.Tests/Authentication/Jwt/JwtAuthorizationRegistrationTests.cs b/test/Mistruna.Core.Tests/Authentication/Jwt/JwtAuthorizationRegistrationTests.cs index 2639451..795181c 100644 --- a/test/Mistruna.Core.Tests/Authentication/Jwt/JwtAuthorizationRegistrationTests.cs +++ b/test/Mistruna.Core.Tests/Authentication/Jwt/JwtAuthorizationRegistrationTests.cs @@ -4,9 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Mistruna.Core.Microservices.Core.JwtAuth; -using Mistruna.Core.Microservices.Core.JwtAuth.Policies; -using Mistruna.Core.Microservices.Core.ServerMiddleware; +using Mistruna.Core.AspNetCore.Authentication; using Xunit; namespace Mistruna.Core.Tests.Authentication.Jwt; @@ -14,7 +12,7 @@ namespace Mistruna.Core.Tests.Authentication.Jwt; public sealed class JwtAuthorizationRegistrationTests { [Fact] - public async Task AddJwtAuthorization_RegistersBearerSchemeFromConfiguration() + public async Task AddMistrunaJwtAuthorization_RegistersBearerSchemeFromConfiguration() { var services = new ServiceCollection(); var configuration = BuildConfiguration(new Dictionary @@ -27,7 +25,7 @@ public async Task AddJwtAuthorization_RegistersBearerSchemeFromConfiguration() services.AddLogging(); services.AddOptions(); - services.AddJwtAuthorization(configuration); + services.AddMistrunaJwtAuthorization(configuration); await using var provider = services.BuildServiceProvider(); var schemeProvider = provider.GetRequiredService(); @@ -43,7 +41,7 @@ public async Task AddJwtAuthorization_RegistersBearerSchemeFromConfiguration() } [Fact] - public void AddJwtAuthorization_ThrowsWhenSigningKeyIsMissing() + public void AddMistrunaJwtAuthorization_ThrowsWhenSigningKeyIsMissing() { var services = new ServiceCollection(); var configuration = BuildConfiguration(new Dictionary @@ -52,45 +50,27 @@ public void AddJwtAuthorization_ThrowsWhenSigningKeyIsMissing() ["Jwt:Audience"] = "ConfiguredAudience" }); - var act = () => services.AddJwtAuthorization(configuration); + var act = () => services.AddMistrunaJwtAuthorization(configuration); act.Should().Throw() .WithMessage("*JWT signing key is not configured*"); } [Fact] - public void AddJwtAuthorization_CanUseToolingFallbackKey() + public void AddMistrunaJwtAuthorization_RegistersAdminPolicy() { var services = new ServiceCollection(); var configuration = BuildConfiguration(new Dictionary { - ["Jwt:Issuer"] = "ConfiguredIssuer" + ["Jwt:Key"] = "12345678901234567890123456789012" }); - services.AddJwtAuthorization(configuration, options => - { - options.AllowToolingFallbackKey = true; - options.ToolingFallbackKey = "00000000-0000-0000-0000-000000000000"; - }); - - using var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService>() - .Get(JwtBearerDefaults.AuthenticationScheme); - - options.TokenValidationParameters.IssuerSigningKey.Should().NotBeNull(); - } - - [Fact] - public void AddCustomAuthorization_RemainsAvailableForLegacyConsumers() - { - var services = new ServiceCollection(); - - services.AddCustomAuthorization(); + services.AddMistrunaJwtAuthorization(configuration); using var provider = services.BuildServiceProvider(); var authorizationOptions = provider.GetRequiredService>().Value; - authorizationOptions.GetPolicy(Policies.AdminOnly).Should().NotBeNull(); + authorizationOptions.GetPolicy(MistrunaAuthorizationPolicies.AdminOnly).Should().NotBeNull(); } private static IConfiguration BuildConfiguration(Dictionary values) diff --git a/test/Mistruna.Core.Tests/Caching/Redis/RedisCacheServiceTests.cs b/test/Mistruna.Core.Tests/Caching/Redis/RedisCacheServiceTests.cs new file mode 100644 index 0000000..4558382 --- /dev/null +++ b/test/Mistruna.Core.Tests/Caching/Redis/RedisCacheServiceTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Mistruna.Core.Abstractions.Persistence; +using Mistruna.Core.Caching.Redis; +using Moq; +using StackExchange.Redis; +using Xunit; + +namespace Mistruna.Core.Tests.Caching.Redis; + +public sealed class RedisCacheServiceTests +{ + [Fact] + public async Task SetAsync_ThenGetAsync_ReturnsRoundtripValue() + { + var storage = new Dictionary(); + var database = new Mock(); + + SetupStringSet(database, storage); + SetupStringGet(database, storage); + + var connection = new Mock(); + connection.Setup(c => c.GetDatabase(-1, null)).Returns(database.Object); + + var cache = new RedisCacheService( + connection.Object, + NullLogger.Instance, + Options.Create(new CacheOptions { KeyPrefix = "test" })); + + await cache.SetAsync("item", new CachePayload { Id = 42, Name = "alpha" }); + + var result = await cache.GetAsync("item"); + + result.Should().NotBeNull(); + result!.Id.Should().Be(42); + result.Name.Should().Be("alpha"); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrueWhenKeyStored() + { + var storage = new Dictionary { ["test:exists"] = "\"yes\"" }; + var database = new Mock(); + database + .Setup(db => db.KeyExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, CommandFlags _) => storage.ContainsKey(key!)); + + var connection = new Mock(); + connection.Setup(c => c.GetDatabase(-1, null)).Returns(database.Object); + + var cache = new RedisCacheService( + connection.Object, + NullLogger.Instance, + Options.Create(new CacheOptions { KeyPrefix = "test" })); + + var exists = await cache.ExistsAsync("exists"); + + exists.Should().BeTrue(); + } + + private static void SetupStringSet(Mock database, Dictionary storage) + { + database + .Setup(db => db.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, value, _, _, _) => storage[key!] = value) + .ReturnsAsync(true); + + database + .Setup(db => db.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, value, _, _, _, _) => storage[key!] = value) + .ReturnsAsync(true); + } + + private static void SetupStringGet(Mock database, Dictionary storage) + { + database + .Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, CommandFlags _) => + storage.TryGetValue(key!, out var value) ? value : RedisValue.Null); + } + + private sealed class CachePayload + { + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + } +} diff --git a/test/Mistruna.Core.Tests/Caching/Redis/RedisPackageTests.cs b/test/Mistruna.Core.Tests/Caching/Redis/RedisPackageTests.cs new file mode 100644 index 0000000..12d5b38 --- /dev/null +++ b/test/Mistruna.Core.Tests/Caching/Redis/RedisPackageTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using Mistruna.Core.Abstractions.Persistence; +using Mistruna.Core.Caching.Redis; +using Xunit; + +namespace Mistruna.Core.Tests.Caching.Redis; + +public sealed class RedisPackageTests +{ + [Fact] + public void RedisCacheService_ShouldImplementAbstractionsContract() + { + typeof(ICacheService) + .IsAssignableFrom(typeof(RedisCacheService)) + .Should() + .BeTrue(); + } + + [Fact] + public void CachingRedisPackage_ShouldNotDependOnAspNetCorePackage() + { + typeof(RedisCacheService) + .Assembly + .GetReferencedAssemblies() + .Should() + .NotContain(reference => reference.Name == "Mistruna.Core.AspNetCore"); + } +} diff --git a/test/Mistruna.Core.Tests/Caching/Redis/RedisRegistrationTests.cs b/test/Mistruna.Core.Tests/Caching/Redis/RedisRegistrationTests.cs new file mode 100644 index 0000000..0cd5ba4 --- /dev/null +++ b/test/Mistruna.Core.Tests/Caching/Redis/RedisRegistrationTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Abstractions.Persistence; +using Mistruna.Core.Caching.Redis; +using Moq; +using StackExchange.Redis; +using Xunit; + +namespace Mistruna.Core.Tests.Caching.Redis; + +public sealed class RedisRegistrationTests +{ + [Fact] + public void AddMistrunaRedis_RegistersCacheService() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(Mock.Of()); + + var configuration = BuildConfiguration(new Dictionary + { + ["ConnectionStrings:Redis"] = "localhost:6379" + }); + + services.AddMistrunaRedis(configuration); + + using var provider = services.BuildServiceProvider(); + provider.GetService().Should().BeOfType(); + } + + [Fact] + public void AddMistrunaRedis_PrefersMistrunaRedisConnectionString() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(Mock.Of()); + + var configuration = BuildConfiguration(new Dictionary + { + ["Mistruna:Redis:ConnectionString"] = "mistruna:6379", + ["ConnectionStrings:Redis"] = "fallback:6379" + }); + + services.AddMistrunaRedis(configuration); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + options.KeyPrefix.Should().BeEmpty(); + } + + [Fact] + public void AddMistrunaRedis_ThrowsWhenConnectionStringMissing() + { + var services = new ServiceCollection(); + var configuration = BuildConfiguration(new Dictionary()); + + var act = () => services.AddMistrunaRedis(configuration); + + act.Should().Throw() + .WithMessage("*Redis connection string is not configured*"); + } + + private static IConfiguration BuildConfiguration(Dictionary values) + => new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); +} diff --git a/test/Mistruna.Core.Tests/Core/RequestValidationBehaviorTests.cs b/test/Mistruna.Core.Tests/Core/RequestValidationBehaviorTests.cs new file mode 100644 index 0000000..7973eca --- /dev/null +++ b/test/Mistruna.Core.Tests/Core/RequestValidationBehaviorTests.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Abstractions.Cqrs; +using Mistruna.Core.DependencyInjection; +using Xunit; + +namespace Mistruna.Core.Tests.Core; + +public sealed class RequestValidationBehaviorTests +{ + [Fact] + public async Task ValidationBehavior_Throws_ValidationException_WhenInvalid() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaCore(options => + { + options.RegisterAssemblies(typeof(PingCommand).Assembly); + options.AddValidation(); + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var mediator = serviceProvider.GetRequiredService(); + + await Assert.ThrowsAsync( + () => mediator.Send(new PingCommand())); + } + + public sealed class PingCommand : ICommand + { + public string? Name { get; init; } + } + + public sealed class PingValidator : AbstractValidator + { + public PingValidator() => RuleFor(command => command.Name).NotEmpty(); + } + + public sealed class PingHandler : IRequestHandler + { + public Task Handle(PingCommand request, CancellationToken cancellationToken) + => Task.FromResult(request.Name ?? string.Empty); + } +} diff --git a/test/Mistruna.Core.Tests/EntityTests.cs b/test/Mistruna.Core.Tests/EntityTests.cs index 89976f6..d3bc07e 100644 --- a/test/Mistruna.Core.Tests/EntityTests.cs +++ b/test/Mistruna.Core.Tests/EntityTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Mistruna.Core.Contracts.Base.Entities; +using Mistruna.Core.Abstractions.Entities; using Xunit; namespace Mistruna.Core.Tests; diff --git a/test/Mistruna.Core.Tests/ExtensionTests.cs b/test/Mistruna.Core.Tests/ExtensionTests.cs index d2d1afd..5cc1f18 100644 --- a/test/Mistruna.Core.Tests/ExtensionTests.cs +++ b/test/Mistruna.Core.Tests/ExtensionTests.cs @@ -4,160 +4,6 @@ namespace Mistruna.Core.Tests; -public class CollectionExtensionsTests -{ - [Fact] - public void IsNullOrEmpty_WithNull_ShouldReturnTrue() - { - // Arrange - IEnumerable? collection = null; - - // Act & Assert - collection.IsNullOrEmpty().Should().BeTrue(); - } - - [Fact] - public void IsNullOrEmpty_WithEmptyCollection_ShouldReturnTrue() - { - // Arrange - var collection = Enumerable.Empty(); - - // Act & Assert - collection.IsNullOrEmpty().Should().BeTrue(); - } - - [Fact] - public void IsNullOrEmpty_WithItems_ShouldReturnFalse() - { - // Arrange - var collection = new[] { 1, 2, 3 }; - - // Act & Assert - collection.IsNullOrEmpty().Should().BeFalse(); - } - - [Fact] - public void HasItems_WithNull_ShouldReturnFalse() - { - // Arrange - IEnumerable? collection = null; - - // Act & Assert - collection.HasItems().Should().BeFalse(); - } - - [Fact] - public void HasItems_WithItems_ShouldReturnTrue() - { - // Arrange - var collection = new[] { 1, 2, 3 }; - - // Act & Assert - collection.HasItems().Should().BeTrue(); - } - - [Fact] - public void OrEmpty_WithNull_ShouldReturnEmpty() - { - // Arrange - IEnumerable? collection = null; - - // Act - var result = collection.OrEmpty(); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public void OrEmpty_WithItems_ShouldReturnSameItems() - { - // Arrange - var collection = new[] { 1, 2, 3 }; - - // Act - var result = collection.OrEmpty(); - - // Assert - result.Should().BeEquivalentTo(collection); - } - - [Fact] - public void ForEach_ShouldExecuteActionOnEachElement() - { - // Arrange - var collection = new[] { 1, 2, 3 }; - var results = new List(); - - // Act - collection.ForEach(x => results.Add(x * 2)); - - // Assert - results.Should().BeEquivalentTo(new[] { 2, 4, 6 }); - } - - [Fact] - public void Batch_ShouldSplitIntoCorrectBatches() - { - // Arrange - var collection = new[] { 1, 2, 3, 4, 5 }; - - // Act - var batches = collection.Batch(2).ToList(); - - // Assert - batches.Should().HaveCount(3); - batches[0].Should().BeEquivalentTo(new[] { 1, 2 }); - batches[1].Should().BeEquivalentTo(new[] { 3, 4 }); - batches[2].Should().BeEquivalentTo(new[] { 5 }); - } - - [Fact] - public void DistinctBy_ShouldRemoveDuplicates() - { - // Arrange - var items = new[] - { - new { Id = 1, Name = "A" }, - new { Id = 1, Name = "B" }, - new { Id = 2, Name = "C" } - }; - - // Act - var result = items.DistinctBy(x => x.Id).ToList(); - - // Assert - result.Should().HaveCount(2); - result.Select(x => x.Id).Should().BeEquivalentTo(new[] { 1, 2 }); - } - - [Fact] - public void WhereNotNull_ShouldFilterOutNulls() - { - // Arrange - var collection = new string?[] { "a", null, "b", null, "c" }; - - // Act - var result = collection.WhereNotNull().ToList(); - - // Assert - result.Should().BeEquivalentTo(new[] { "a", "b", "c" }); - } - - [Fact] - public void Shuffle_ShouldContainSameElements() - { - // Arrange - var original = new[] { 1, 2, 3, 4, 5 }; - - // Act - var shuffled = original.Shuffle().ToList(); - - // Assert - shuffled.Should().BeEquivalentTo(original); - } -} - public class StringExtensionsTests { [Theory] diff --git a/test/Mistruna.Core.Tests/Messaging/RabbitMqTests.cs b/test/Mistruna.Core.Tests/Messaging/RabbitMqTests.cs new file mode 100644 index 0000000..2c426d1 --- /dev/null +++ b/test/Mistruna.Core.Tests/Messaging/RabbitMqTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Mistruna.Core.Messaging.RabbitMq; +using Mistruna.Core.Messaging.RabbitMq.DependencyInjection; +using Mistruna.Core.Messaging.RabbitMq.Internal; +using RabbitMQ.Client; +using Xunit; + +namespace Mistruna.Core.Tests.Messaging; + +public sealed class RabbitMqTests +{ + [Fact] + public void Serializer_RoundTrips_Message_Using_SystemTextJson() + { + var message = new TestMessage("hello", 42); + + var bytes = RabbitMessageSerializer.Serialize(message); + var deserialized = RabbitMessageSerializer.Deserialize(bytes); + + Assert.Equal(message, deserialized); + Assert.Contains("\"text\":\"hello\"", Encoding.UTF8.GetString(bytes.Span)); + } + + [Fact] + public async Task PublishAsync_Forwards_Topology_And_Persistent_Json_Message() + { + var channel = new RecordingPublisherChannel(); + var bus = new RabbitBus(channel); + var message = new TestMessage("hello", 42); + + await bus.PublishAsync(message, "events", "test.created", CancellationToken.None); + + Assert.Equal("events", channel.Exchange); + Assert.Equal("test.created", channel.RoutingKey); + Assert.True(channel.Properties?.Persistent); + Assert.Equal(nameof(TestMessage), channel.Properties?.Type); + Assert.Equal("application/json", channel.Properties?.ContentType); + Assert.Equal(message, RabbitMessageSerializer.Deserialize(channel.Body)); + } + + [Fact] + public async Task AddMistrunaRabbitMq_Registers_Bus_Without_Opening_A_Connection() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Mistruna:RabbitMq:HostName"] = "broker", + ["Mistruna:RabbitMq:UserName"] = "guest", + ["Mistruna:RabbitMq:Password"] = "guest" + }) + .Build(); + var services = new ServiceCollection(); + + services.AddMistrunaRabbitMq(configuration); + + await using var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService()); + Assert.Equal("broker", provider.GetRequiredService>().Value.HostName); + } + + [Fact] + public void AddMistrunaRabbitConsumer_Registers_Handler_And_Hosted_Service() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaRabbitMq(new ConfigurationBuilder().Build()); + + services.AddMistrunaRabbitConsumer( + queue: "test-messages", + exchange: "events", + routingKey: "test.created"); + + using var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService>()); + Assert.Contains( + provider.GetServices(), + service => service.GetType().IsGenericType + && service.GetType().GetGenericTypeDefinition() == typeof(RabbitConsumerHostedService<,>)); + var topology = provider.GetRequiredService>(); + Assert.Equal("test-messages", topology.Queue); + Assert.Equal("events", topology.Exchange); + Assert.Equal("test.created", topology.RoutingKey); + Assert.Equal(ExchangeType.Topic, topology.ExchangeType); + Assert.True(topology.Durable); + } + + private sealed record TestMessage(string Text, int Count); + + private sealed class TestHandler : IMistrunaRabbitMessageHandler + { + public Task HandleAsync(TestMessage message, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + private sealed class RecordingPublisherChannel : IRabbitPublisherChannel + { + public string? Exchange { get; private set; } + + public string? RoutingKey { get; private set; } + + public BasicProperties? Properties { get; private set; } + + public ReadOnlyMemory Body { get; private set; } + + public Task PublishAsync( + string exchange, + string routingKey, + BasicProperties properties, + ReadOnlyMemory body, + CancellationToken cancellationToken) + { + Exchange = exchange; + RoutingKey = routingKey; + Properties = properties; + Body = body; + return Task.CompletedTask; + } + } +} diff --git a/test/Mistruna.Core.Tests/Mistruna.Core.Tests.csproj b/test/Mistruna.Core.Tests/Mistruna.Core.Tests.csproj index cbee4d8..3da1af0 100644 --- a/test/Mistruna.Core.Tests/Mistruna.Core.Tests.csproj +++ b/test/Mistruna.Core.Tests/Mistruna.Core.Tests.csproj @@ -9,30 +9,41 @@ - - - - - - - - + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + - + diff --git a/test/Mistruna.Core.Tests/Authentication/ApiKey/ApiKeyAuthenticationHandlerTests.cs b/test/Mistruna.Core.Tests/Monetization/ApiKey/ApiKeyAuthenticationHandlerTests.cs similarity index 93% rename from test/Mistruna.Core.Tests/Authentication/ApiKey/ApiKeyAuthenticationHandlerTests.cs rename to test/Mistruna.Core.Tests/Monetization/ApiKey/ApiKeyAuthenticationHandlerTests.cs index 649070d..57c8be7 100644 --- a/test/Mistruna.Core.Tests/Authentication/ApiKey/ApiKeyAuthenticationHandlerTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/ApiKey/ApiKeyAuthenticationHandlerTests.cs @@ -1,18 +1,18 @@ using System.Security.Claims; using System.Text.Encodings.Web; -using Microsoft.Extensions.DependencyInjection; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authorization.Plans; using Moq; using Xunit; -namespace Mistruna.Core.Tests.Authentication.ApiKey; +namespace Mistruna.Core.Tests.Monetization.ApiKey; public class ApiKeyAuthenticationHandlerTests { @@ -106,8 +106,10 @@ private static async Task CreateHandler( .BuildServiceProvider(); var context = new DefaultHttpContext { RequestServices = services }; - foreach (var h in headers) context.Request.Headers[h.Key] = h.Value; - if (query is not null) context.Request.QueryString = new QueryString(query); + foreach (var h in headers) + context.Request.Headers[h.Key] = h.Value; + if (query is not null) + context.Request.QueryString = new QueryString(query); var options = new ApiKeyAuthenticationOptions(); configure?.Invoke(options); diff --git a/test/Mistruna.Core.Tests/Idempotency/IdempotencyMiddlewareTests.cs b/test/Mistruna.Core.Tests/Monetization/Idempotency/IdempotencyMiddlewareTests.cs similarity index 94% rename from test/Mistruna.Core.Tests/Idempotency/IdempotencyMiddlewareTests.cs rename to test/Mistruna.Core.Tests/Monetization/Idempotency/IdempotencyMiddlewareTests.cs index daffe98..a4e76a6 100644 --- a/test/Mistruna.Core.Tests/Idempotency/IdempotencyMiddlewareTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/Idempotency/IdempotencyMiddlewareTests.cs @@ -3,12 +3,12 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Idempotency; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Idempotency; using Moq; using Xunit; -namespace Mistruna.Core.Tests.Idempotency; +namespace Mistruna.Core.Tests.Monetization.Idempotency; public class IdempotencyMiddlewareTests { @@ -99,7 +99,8 @@ private static DefaultHttpContext BuildContext(string method, string? idemKey, s { var context = new DefaultHttpContext(); context.Request.Method = method; - if (idemKey is not null) context.Request.Headers["Idempotency-Key"] = idemKey; + if (idemKey is not null) + context.Request.Headers["Idempotency-Key"] = idemKey; var identity = new ClaimsIdentity("ApiKey"); identity.AddClaim(new Claim(ApiKeyClaimTypes.Subject, userId)); diff --git a/test/Mistruna.Core.Tests/Metering/UsageMeteringMiddlewareTests.cs b/test/Mistruna.Core.Tests/Monetization/Metering/UsageMeteringMiddlewareTests.cs similarity index 95% rename from test/Mistruna.Core.Tests/Metering/UsageMeteringMiddlewareTests.cs rename to test/Mistruna.Core.Tests/Monetization/Metering/UsageMeteringMiddlewareTests.cs index 2798aef..399e78a 100644 --- a/test/Mistruna.Core.Tests/Metering/UsageMeteringMiddlewareTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/Metering/UsageMeteringMiddlewareTests.cs @@ -2,12 +2,12 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Metering; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Metering; using Moq; using Xunit; -namespace Mistruna.Core.Tests.Metering; +namespace Mistruna.Core.Tests.Monetization.Metering; public class UsageMeteringMiddlewareTests { diff --git a/test/Mistruna.Core.Tests/Monetization/MonetizationRegistrationTests.cs b/test/Mistruna.Core.Tests/Monetization/MonetizationRegistrationTests.cs new file mode 100644 index 0000000..71c0f9a --- /dev/null +++ b/test/Mistruna.Core.Tests/Monetization/MonetizationRegistrationTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Monetization.Authorization.Plans; +using Mistruna.Core.Monetization.DependencyInjection; +using Mistruna.Core.Monetization.Idempotency; +using Mistruna.Core.Monetization.Metering; +using Mistruna.Core.Monetization.RateLimiting.Tiered; +using Xunit; + +namespace Mistruna.Core.Tests.Monetization; + +public sealed class MonetizationRegistrationTests +{ + [Fact] + public void AddMistrunaApiKeyAuthentication_IsAvailableOnServiceCollection() + { + var services = new ServiceCollection(); + + var result = services.AddMistrunaApiKeyAuthentication(); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddMistrunaMonetization_RegistersAllMonetizationServices() + { + var services = new ServiceCollection(); + + services.AddMistrunaMonetization(); + + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IQuotaStore) + && descriptor.ImplementationType == typeof(RedisQuotaStore)); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IIdempotencyStore) + && descriptor.ImplementationType == typeof(RedisIdempotencyStore)); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IUsageMeter) + && descriptor.ImplementationType == typeof(RedisUsageMeter)); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IAuthorizationPolicyProvider) + && descriptor.ImplementationType == typeof(RequiresPlanPolicyProvider)); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IAuthorizationMiddlewareResultHandler) + && descriptor.ImplementationType == typeof(PlanAuthorizationMiddlewareResultHandler)); + } + + [Fact] + public void UseMistrunaMonetization_AddsTheMonetizationPipeline() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaMonetization(); + using var provider = services.BuildServiceProvider(); + var app = new ApplicationBuilder(provider); + + var result = app.UseMistrunaMonetization(); + + result.Should().BeSameAs(app); + app.Build().Should().NotBeNull(); + } +} diff --git a/test/Mistruna.Core.Tests/Authorization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs b/test/Mistruna.Core.Tests/Monetization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs similarity index 95% rename from test/Mistruna.Core.Tests/Authorization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs rename to test/Mistruna.Core.Tests/Monetization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs index c3a92fd..701b22b 100644 --- a/test/Mistruna.Core.Tests/Authorization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/Plans/PlanAuthorizationMiddlewareResultHandlerTests.cs @@ -3,10 +3,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http; -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authorization.Plans; using Xunit; -namespace Mistruna.Core.Tests.Authorization.Plans; +namespace Mistruna.Core.Tests.Monetization.Plans; public class PlanAuthorizationMiddlewareResultHandlerTests { diff --git a/test/Mistruna.Core.Tests/Authorization/Plans/RequiresPlanAuthorizationHandlerTests.cs b/test/Mistruna.Core.Tests/Monetization/Plans/RequiresPlanAuthorizationHandlerTests.cs similarity index 95% rename from test/Mistruna.Core.Tests/Authorization/Plans/RequiresPlanAuthorizationHandlerTests.cs rename to test/Mistruna.Core.Tests/Monetization/Plans/RequiresPlanAuthorizationHandlerTests.cs index f9f46b1..c145221 100644 --- a/test/Mistruna.Core.Tests/Authorization/Plans/RequiresPlanAuthorizationHandlerTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/Plans/RequiresPlanAuthorizationHandlerTests.cs @@ -1,10 +1,10 @@ using System.Security.Claims; using FluentAssertions; using Microsoft.AspNetCore.Authorization; -using Mistruna.Core.Authorization.Plans; +using Mistruna.Core.Monetization.Authorization.Plans; using Xunit; -namespace Mistruna.Core.Tests.Authorization.Plans; +namespace Mistruna.Core.Tests.Monetization.Plans; public class RequiresPlanAuthorizationHandlerTests { diff --git a/test/Mistruna.Core.Tests/RateLimiting/Tiered/InMemoryQuotaStoreTests.cs b/test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/InMemoryQuotaStoreTests.cs similarity index 92% rename from test/Mistruna.Core.Tests/RateLimiting/Tiered/InMemoryQuotaStoreTests.cs rename to test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/InMemoryQuotaStoreTests.cs index e1f427c..098f4a3 100644 --- a/test/Mistruna.Core.Tests/RateLimiting/Tiered/InMemoryQuotaStoreTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/InMemoryQuotaStoreTests.cs @@ -1,8 +1,8 @@ using FluentAssertions; -using Mistruna.Core.RateLimiting.Tiered; +using Mistruna.Core.Monetization.RateLimiting.Tiered; using Xunit; -namespace Mistruna.Core.Tests.RateLimiting.Tiered; +namespace Mistruna.Core.Tests.Monetization.TieredRateLimiting; public class InMemoryQuotaStoreTests { diff --git a/test/Mistruna.Core.Tests/RateLimiting/Tiered/TieredRateLimitMiddlewareTests.cs b/test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/TieredRateLimitMiddlewareTests.cs similarity index 93% rename from test/Mistruna.Core.Tests/RateLimiting/Tiered/TieredRateLimitMiddlewareTests.cs rename to test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/TieredRateLimitMiddlewareTests.cs index f008843..8b604c6 100644 --- a/test/Mistruna.Core.Tests/RateLimiting/Tiered/TieredRateLimitMiddlewareTests.cs +++ b/test/Mistruna.Core.Tests/Monetization/TieredRateLimiting/TieredRateLimitMiddlewareTests.cs @@ -2,12 +2,12 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Mistruna.Core.Authentication.ApiKey; -using Mistruna.Core.Authorization.Plans; -using Mistruna.Core.RateLimiting.Tiered; +using Mistruna.Core.Monetization.Authentication.ApiKey; +using Mistruna.Core.Monetization.Authorization.Plans; +using Mistruna.Core.Monetization.RateLimiting.Tiered; using Xunit; -namespace Mistruna.Core.Tests.RateLimiting.Tiered; +namespace Mistruna.Core.Tests.Monetization.TieredRateLimiting; public class TieredRateLimitMiddlewareTests { diff --git a/test/Mistruna.Core.Tests/Observability/ObservabilityRegistrationTests.cs b/test/Mistruna.Core.Tests/Observability/ObservabilityRegistrationTests.cs new file mode 100644 index 0000000..18d86cf --- /dev/null +++ b/test/Mistruna.Core.Tests/Observability/ObservabilityRegistrationTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Observability.DependencyInjection; +using Xunit; + +namespace Mistruna.Core.Tests.Observability; + +public sealed class ObservabilityRegistrationTests +{ + [Fact] + public void AddMistrunaObservability_ShouldConfigureOtlpExporterWhenEndpointIsPresent() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OpenTelemetry:Otlp:Endpoint"] = "http://collector:4317" + }) + .Build(); + var services = new ServiceCollection(); + var servicesWithoutEndpoint = new ServiceCollection(); + + services.AddMistrunaObservability(configuration); + servicesWithoutEndpoint.AddMistrunaObservability(new ConfigurationBuilder().Build()); + + CountOtlpRegistrations(services).Should().BeGreaterThan(CountOtlpRegistrations(servicesWithoutEndpoint)); + } + + private static int CountOtlpRegistrations(IServiceCollection services) => + services.Count(descriptor => + descriptor.ToString().Contains("Otlp", StringComparison.OrdinalIgnoreCase)); +} diff --git a/test/Mistruna.Core.Tests/Observability/OpenTelemetryMediatorBehaviorTests.cs b/test/Mistruna.Core.Tests/Observability/OpenTelemetryMediatorBehaviorTests.cs new file mode 100644 index 0000000..091b249 --- /dev/null +++ b/test/Mistruna.Core.Tests/Observability/OpenTelemetryMediatorBehaviorTests.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using FluentAssertions; +using MediatR; +using Mistruna.Core.Abstractions.Cqrs; +using Mistruna.Core.Abstractions.Results; +using Mistruna.Core.Observability; +using Mistruna.Core.Observability.Behaviors; +using Xunit; + +namespace Mistruna.Core.Tests.Observability; + +public sealed class OpenTelemetryMediatorBehaviorTests : IDisposable +{ + private sealed record SampleCommand : ICommand; + + private sealed record SampleQuery : IQuery; + + private readonly List _activities = []; + private readonly ActivityListener _listener; + + public OpenTelemetryMediatorBehaviorTests() + { + _listener = new ActivityListener + { + ShouldListenTo = source => source.Name == MistrunaMediatorTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => _activities.Add(activity), + }; + + ActivitySource.AddActivityListener(_listener); + } + + [Fact] + public async Task Handle_sets_request_type_and_request_kind_for_command() + { + var behavior = new OpenTelemetryMediatorBehavior(); + + await behavior.Handle( + new SampleCommand(), + _ => Task.FromResult("ok"), + CancellationToken.None); + + var activity = _activities.Should().ContainSingle().Subject; + activity.GetTagItem("request.type").Should().Be(typeof(SampleCommand).FullName); + activity.GetTagItem("request.kind").Should().Be("command"); + activity.GetTagItem("outcome").Should().Be("success"); + } + + [Fact] + public async Task Handle_sets_request_type_and_request_kind_for_query() + { + var behavior = new OpenTelemetryMediatorBehavior(); + + await behavior.Handle( + new SampleQuery(), + _ => Task.FromResult(42), + CancellationToken.None); + + var activity = _activities.Should().ContainSingle().Subject; + activity.GetTagItem("request.type").Should().Be(typeof(SampleQuery).FullName); + activity.GetTagItem("request.kind").Should().Be("query"); + activity.GetTagItem("outcome").Should().Be("success"); + } + + [Fact] + public async Task Handle_sets_outcome_failure_when_result_fails() + { + var behavior = new OpenTelemetryMediatorBehavior>(); + + await behavior.Handle( + new SampleCommand(), + _ => Task.FromResult(Result.Failure(Error.Failure("Test.Failed", "failed"))), + CancellationToken.None); + + var activity = _activities.Should().ContainSingle().Subject; + activity.GetTagItem("outcome").Should().Be("failure"); + activity.Status.Should().Be(ActivityStatusCode.Error); + } + + [Fact] + public async Task Handle_sets_outcome_error_when_handler_throws() + { + var behavior = new OpenTelemetryMediatorBehavior(); + + await Assert.ThrowsAsync(() => + behavior.Handle( + new SampleCommand(), + _ => throw new InvalidOperationException("boom"), + CancellationToken.None)); + + var activity = _activities.Should().ContainSingle().Subject; + activity.GetTagItem("outcome").Should().Be("error"); + activity.Status.Should().Be(ActivityStatusCode.Error); + } + + public void Dispose() => _listener.Dispose(); +} diff --git a/test/Mistruna.Core.Tests/PageViewResponseExtensionsTests.cs b/test/Mistruna.Core.Tests/PageViewResponseExtensionsTests.cs index b0c1586..9584a10 100644 --- a/test/Mistruna.Core.Tests/PageViewResponseExtensionsTests.cs +++ b/test/Mistruna.Core.Tests/PageViewResponseExtensionsTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Mistruna.Core.Contracts.Base.Responses; +using Mistruna.Core.Abstractions.Responses; using Xunit; namespace Mistruna.Core.Tests; diff --git a/test/Mistruna.Core.Tests/Persistence/PersistenceContractTests.cs b/test/Mistruna.Core.Tests/Persistence/PersistenceContractTests.cs index a30e273..68d38bf 100644 --- a/test/Mistruna.Core.Tests/Persistence/PersistenceContractTests.cs +++ b/test/Mistruna.Core.Tests/Persistence/PersistenceContractTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; -using Mistruna.Core.Contracts.Base.Infrastructure; +using Mistruna.Core.Abstractions.Persistence; +using Mistruna.Core.EfCore; using Xunit; namespace Mistruna.Core.Tests.Persistence; @@ -74,6 +75,25 @@ public void LegacyDbTransactionContract_ShouldRemainCompatibleWithExplicitTransa .BeTrue(); } + [Fact] + public void EfCoreRepository_ShouldImplementAbstractionsContract() + { + typeof(IGenericRepository) + .IsAssignableFrom(typeof(EfGenericRepository)) + .Should() + .BeTrue(); + } + + [Fact] + public void EfCorePackage_ShouldNotDependOnAspNetCorePackage() + { + typeof(EfGenericRepository<>) + .Assembly + .GetReferencedAssemblies() + .Should() + .NotContain(reference => reference.Name == "Mistruna.Core.AspNetCore"); + } + private sealed class TestEntity : IEntity { public Guid Id { get; set; } diff --git a/test/Mistruna.Core.Tests/Persistence/UnitOfWorkTests.cs b/test/Mistruna.Core.Tests/Persistence/UnitOfWorkTests.cs index 9bfe7cc..253763a 100644 --- a/test/Mistruna.Core.Tests/Persistence/UnitOfWorkTests.cs +++ b/test/Mistruna.Core.Tests/Persistence/UnitOfWorkTests.cs @@ -3,9 +3,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; -using Mistruna.Core.Base.Persistence; -using Mistruna.Core.Contracts.Base.Infrastructure; -using Mistruna.Core.Extensions; +using Mistruna.Core.Abstractions.Persistence; +using Mistruna.Core.EfCore; using Xunit; namespace Mistruna.Core.Tests.Persistence; @@ -241,19 +240,67 @@ public void UnitOfWorkContracts_ShouldSupportAsyncDisposal() } [Fact] - public async Task AddEfUnitOfWork_ShouldRegisterScopedUnitOfWork() + public async Task Dispose_ShouldNotDisposeInjectedDbContext() + { + await using var database = await TestDatabase.CreateAsync(); + await using var context = database.CreateContext(); + var unitOfWork = new EfUnitOfWork(context); + + unitOfWork.Dispose(); + + var action = () => context.Entities.Any(); + action.Should().NotThrow(); + } + + [Fact] + public async Task DisposeAsync_ShouldNotDisposeInjectedDbContext() + { + await using var database = await TestDatabase.CreateAsync(); + await using var context = database.CreateContext(); + var unitOfWork = new EfUnitOfWork(context); + + await unitOfWork.DisposeAsync(); + + var action = () => context.Entities.AnyAsync(); + await action.Should().NotThrowAsync(); + } + + [Fact] + public async Task AddMistrunaEfCore_ShouldRegisterScopedPersistenceServices() { await using var database = await TestDatabase.CreateAsync(); var services = new ServiceCollection(); services.AddDbContext(options => options.UseSqlite(database.Connection)); - services.AddEfUnitOfWork(); + services.AddMistrunaEfCore(); await using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService>(); unitOfWork.Should().BeOfType>(); + repository.Should().BeOfType>(); + } + + [Fact] + public async Task RegisteredServices_ShouldPersistChangesWithEfInMemory() + { + var services = new ServiceCollection(); + services.AddDbContext( + options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddMistrunaEfCore(); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService>(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await repository.AddAsync(new TestEntity { Id = Guid.NewGuid(), Name = "in-memory" }); + await unitOfWork.SaveChangesAsync(); + + var context = scope.ServiceProvider.GetRequiredService(); + (await context.Entities.SingleAsync()).Name.Should().Be("in-memory"); } private sealed class TestDatabase : IAsyncDisposable @@ -355,5 +402,5 @@ private sealed class TestDbContext(DbContextOptions options) : Db } private sealed class TestEntityRepository(TestDbContext context) - : EfGenericRepository(context, context.Entities); + : EfGenericRepository(context); } diff --git a/test/Mistruna.Core.Tests/Resilience/ResilienceRegistrationTests.cs b/test/Mistruna.Core.Tests/Resilience/ResilienceRegistrationTests.cs new file mode 100644 index 0000000..780a143 --- /dev/null +++ b/test/Mistruna.Core.Tests/Resilience/ResilienceRegistrationTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Resilience; +using Mistruna.Core.Resilience.DependencyInjection; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Mistruna.Core.Tests.Resilience; + +public sealed class ResilienceRegistrationTests +{ + [Fact] + public void AddMistrunaResilience_RegistersNamedHttpClientPresets() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaResilience(); + + using var provider = services.BuildServiceProvider(); + var pipelineProvider = provider.GetRequiredService>(); + + pipelineProvider.GetPipeline(MistrunaResiliencePresets.Standard).Should().NotBeNull(); + pipelineProvider.GetPipeline(MistrunaResiliencePresets.Aggressive).Should().NotBeNull(); + pipelineProvider.GetPipeline(MistrunaResiliencePresets.Disable).Should().NotBeNull(); + } + + [Fact] + public void AddMistrunaResilienceHandler_Standard_ConfiguresHttpClient() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaResilience(); + services.AddHttpClient("test").AddMistrunaResilienceHandler(MistrunaResiliencePresets.Standard); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService().CreateClient("test"); + + client.Should().NotBeNull(); + } + + [Fact] + public void AddMistrunaResilienceHandler_Disable_DoesNotAddResilienceHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaResilience(); + services.AddHttpClient("test").AddMistrunaResilienceHandler(MistrunaResiliencePresets.Disable); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService().CreateClient("test"); + + client.Should().NotBeNull(); + } +} diff --git a/test/Mistruna.Core.Tests/Resilience/ResilientCommandBehaviorTests.cs b/test/Mistruna.Core.Tests/Resilience/ResilientCommandBehaviorTests.cs new file mode 100644 index 0000000..977d42b --- /dev/null +++ b/test/Mistruna.Core.Tests/Resilience/ResilientCommandBehaviorTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Mistruna.Core.Abstractions.Cqrs; +using Mistruna.Core.Resilience; +using Mistruna.Core.Resilience.Behaviors; +using Mistruna.Core.Resilience.DependencyInjection; +using Polly.Registry; +using Xunit; + +namespace Mistruna.Core.Tests.Resilience; + +public sealed class ResilientCommandBehaviorTests : IDisposable +{ + private sealed record UnmarkedCommand : ICommand; + + [Resilient] + private sealed record MarkedCommand : ICommand; + + private readonly ServiceProvider _provider; + + public ResilientCommandBehaviorTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMistrunaResilience(); + _provider = services.BuildServiceProvider(); + } + + [Fact] + public async Task Handle_does_not_retry_unmarked_command() + { + var attempts = 0; + var behavior = CreateBehavior(); + + await Assert.ThrowsAsync(() => + behavior.Handle( + new UnmarkedCommand(), + _ => + { + attempts++; + throw new InvalidOperationException("boom"); + }, + CancellationToken.None)); + + attempts.Should().Be(1); + } + + [Fact] + public async Task Handle_retries_marked_command_on_transient_failure() + { + var attempts = 0; + var behavior = CreateBehavior(); + + var result = await behavior.Handle( + new MarkedCommand(), + _ => + { + attempts++; + if (attempts < 3) + throw new InvalidOperationException("transient"); + + return Task.FromResult("ok"); + }, + CancellationToken.None); + + result.Should().Be("ok"); + attempts.Should().Be(3); + } + + private ResilientCommandBehavior CreateBehavior() + where TRequest : notnull + => new(_provider.GetRequiredService>()); + + public void Dispose() => _provider.Dispose(); +} diff --git a/test/Mistruna.Core.Tests/ResultTests.cs b/test/Mistruna.Core.Tests/ResultTests.cs index bd29ae9..cd3c660 100644 --- a/test/Mistruna.Core.Tests/ResultTests.cs +++ b/test/Mistruna.Core.Tests/ResultTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Mistruna.Core.Contracts.Base.Results; +using Mistruna.Core.Abstractions.Results; using Xunit; namespace Mistruna.Core.Tests; diff --git a/test/Mistruna.Core.Tests/Testing/TestAsyncProviderTests.cs b/test/Mistruna.Core.Tests/Testing/TestAsyncProviderTests.cs new file mode 100644 index 0000000..148e4b2 --- /dev/null +++ b/test/Mistruna.Core.Tests/Testing/TestAsyncProviderTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Mistruna.Core.Abstractions.Results; +using Mistruna.Core.Testing.EfCore; +using Mistruna.Core.Testing.Results; +using Xunit; + +namespace Mistruna.Core.Tests.Testing; + +public sealed class TestAsyncProviderTests +{ + [Fact] + public async Task AsyncQueryable_ShouldSupportEfCoreAsyncLinq() + { + var items = new[] { 1, 2, 3 }; + var queryable = AsyncQueryable.From(items); + + var count = await queryable.CountAsync(); + var list = await queryable.ToListAsync(); + + count.Should().Be(3); + list.Should().Equal(items); + } + + [Fact] + public async Task TestAsyncEnumerable_ShouldEnumerateAsynchronously() + { + var items = new[] { 10, 20 }; + var values = new List(); + + await foreach (var item in new TestAsyncEnumerable(items)) + values.Add(item); + + values.Should().Equal(items); + } + + [Fact] + public async Task TestAsyncQueryProvider_ShouldExecuteAsyncQueries() + { + var queryable = AsyncQueryable.From(new[] { "alpha", "beta" }); + + var first = await queryable.FirstAsync(); + + first.Should().Be("alpha"); + } +} + +public sealed class ResultAssertionTests +{ + [Fact] + public void ShouldBeSuccess_ShouldPassForSuccessfulResult() + { + var result = Result.Success("ok"); + + result.ShouldBeSuccessWithValue("ok"); + } + + [Fact] + public void ShouldBeFailure_ShouldPassForFailedResult() + { + var error = Error.NotFound("ITEM_NOT_FOUND", "Missing"); + var result = Result.Failure(error); + + result.ShouldBeFailure("ITEM_NOT_FOUND"); + } +} + +public sealed class TestingPackageTests +{ + [Fact] + public void TestingPackage_ShouldExposePublicAsyncTestHelpers() + { + typeof(TestAsyncQueryProvider<>).IsPublic.Should().BeTrue(); + typeof(TestAsyncEnumerable<>).IsPublic.Should().BeTrue(); + typeof(AsyncQueryable).IsPublic.Should().BeTrue(); + typeof(ResultTestExtensions).IsPublic.Should().BeTrue(); + } + + [Fact] + public void TestingPackage_ShouldNotDependOnCoreMonolith() + { + typeof(AsyncQueryable) + .Assembly + .GetReferencedAssemblies() + .Should() + .NotContain(reference => reference.Name == "Mistruna.Core"); + } +}