├── .gitattributes ├── .gitignore ├── Dockerfile ├── FeatureManagementFilters.sln ├── LICENSE.txt ├── README.md ├── docker-compose.yml ├── src ├── EventBusRabbitMQ │ ├── Domain │ │ ├── Enums.cs │ │ └── Transaction.cs │ ├── EventBus.csproj │ ├── EventBusOptions.cs │ ├── Events │ │ ├── IAllowDirectFallback.cs │ │ ├── IIntegrationEventHandler.cs │ │ └── IntegrationEvent.cs │ ├── Extensions │ │ ├── EventBusExtensions.cs │ │ └── ModelBuilderExtension.cs │ ├── Infrastructure │ │ ├── DbContext │ │ │ ├── DbSeeder.cs │ │ │ └── EventBusDbContext.cs │ │ ├── EventBus │ │ │ ├── EventBus.cs │ │ │ ├── IEventBus.cs │ │ │ ├── IRabbitMQPersistentConnection.cs │ │ │ ├── RabbitMQPersistentConnection.cs │ │ │ └── ResiliencePipelineFactory.cs │ │ ├── EventBusSubscriptionInfo.cs │ │ ├── Messaging │ │ │ ├── IMessageDeduplicationService.cs │ │ │ ├── ITransactionalOutbox.cs │ │ │ ├── MessageDeduplicationService.cs │ │ │ ├── MessageProcessor.cs │ │ │ ├── OutBoxWorker.cs │ │ │ ├── ResilientTransaction.cs │ │ │ └── TransactionalOutbox.cs │ │ ├── RabbitHealthCheck.cs │ │ └── RabbitMQConstants.cs │ ├── Utilities │ │ └── MessageHelper.cs │ └── appsettings.json ├── FeatureFusion.ApiGateway │ ├── FeatureFusion.ApiGateway.csproj │ ├── FeatureFusion.ApiGateway.http │ ├── Program.cs │ ├── RateLimiter │ │ ├── Enums │ │ │ ├── Extensions │ │ │ │ └── EnumExtension.cs │ │ │ └── RateLimiterPolicy.cs │ │ ├── MemcachedClientFactory.cs │ │ ├── MemcachedFixedWindowRateLimiterOptions.cs │ │ ├── MemcachedRateLimiter.cs │ │ ├── MemcachedRateLimiterPartition.cs │ │ ├── MemcachedRatelimiterPolicy.cs │ │ ├── RateLimitMetadata.cs │ │ └── ResilencePolicy │ │ │ └── AsyncPolicy.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── FeatureFusion.AppHost.AppHost │ ├── Extensions.cs │ ├── FeatureFusion.AppHost.csproj │ ├── Program.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── FeatureFusion.AppHost.ServiceDefaults │ ├── Extensions.cs │ └── FeatureFusion.AppHost.ServiceDefaults.csproj ├── FeatureFusion │ ├── .http │ ├── Apis │ │ └── MinimalApiGreeting.cs │ ├── Controllers │ │ ├── V1 │ │ │ ├── Authentication.cs │ │ │ └── GreetingController.cs │ │ └── V2 │ │ │ ├── Authentication.cs │ │ │ ├── GreetingController.cs │ │ │ ├── OrderController.cs │ │ │ └── ProductController.cs │ ├── Domain │ │ └── Entities │ │ │ ├── BaseEntitiy.cs │ │ │ ├── Person.cs │ │ │ ├── Product.cs │ │ │ └── ProductManufacturer.cs │ ├── Dtos │ │ ├── GreetingDto.cs │ │ ├── LoginDto.cs │ │ ├── PersonDto.cs │ │ ├── ProductDto.cs │ │ ├── ProductPromotionDto.cs │ │ ├── UserDto.cs │ │ └── Validator │ │ │ ├── BaseValidator.cs │ │ │ ├── GetProductsCommandValidator.cs │ │ │ ├── GreetingValidator.cs │ │ │ ├── OrderRequestValidator.cs │ │ │ └── ValidationResultWrapper.cs │ ├── FeatureFusion.csproj │ ├── Features │ │ ├── Orders │ │ │ ├── Behavior │ │ │ │ ├── LoggingBehavior.cs │ │ │ │ └── TelemetryBehavior.cs │ │ │ ├── Commands │ │ │ │ ├── CreateOrderCommand.cs │ │ │ │ ├── CreateOrderCommandHandler.cs │ │ │ │ ├── CreateOrderCommandVoid.cs │ │ │ │ └── CreateOrderCommandVoidHandler.cs │ │ │ ├── IntegrationEvents │ │ │ │ ├── EventHandling │ │ │ │ │ └── OrderCreatedEventHandler.cs │ │ │ │ ├── Events │ │ │ │ │ ├── IAllowDirectFallback.cs │ │ │ │ │ └── OrderCreatedEvent.cs │ │ │ │ ├── IIntegrationEventService.cs │ │ │ │ └── IntegrationEventService.cs │ │ │ ├── Queries │ │ │ │ └── GetOrderQuery.cs │ │ │ └── Types │ │ │ │ └── Results.cs │ │ └── Products │ │ │ └── Queries │ │ │ ├── GetProductsQuery.cs │ │ │ └── GetProductsQueryHandler.cs │ ├── Infrastructure │ │ ├── CQRS │ │ │ ├── Adapter │ │ │ │ └── Adapter.cs │ │ │ ├── IMediator.cs │ │ │ ├── Mediator.cs │ │ │ ├── Unit.cs │ │ │ └── Wrapper │ │ │ │ ├── PipelineBehaviorWrappers.cs │ │ │ │ └── RequestHandlerWrappers.cs │ │ ├── Caching │ │ │ ├── CacheKey.cs │ │ │ ├── CacheKeyService.cs │ │ │ ├── IDistributedCacheManager.cs │ │ │ ├── IRedisConnectionWrapper.cs │ │ │ ├── IStaticCacheManager.cs │ │ │ ├── MemcachedCacheManager.cs │ │ │ ├── MemoryCacheManager.cs │ │ │ ├── RedisCacheManager.cs │ │ │ ├── RedisConnectionWrapper.cs │ │ │ └── RedisOptions.cs │ │ ├── CursorPagination │ │ │ ├── CursorFactory.cs │ │ │ ├── PagedResult.cs │ │ │ └── PaginationHelper.cs │ │ ├── DbContext │ │ │ ├── CatalogDContextSeed.cs │ │ │ └── CatalogDbContext.cs │ │ ├── EntitiyConfiguration │ │ │ └── ProductEntityTypeConfiguration.cs │ │ ├── Exetnsion │ │ │ ├── ActivityExtensions.cs │ │ │ ├── BuilderExtensions.cs │ │ │ ├── EndpointExtension.cs │ │ │ ├── MigrateDbContextExtensions.cs │ │ │ ├── ProductExtension.cs │ │ │ ├── RouteHandlerBuilderExtension.cs │ │ │ └── ValidationProblemHelper.cs │ │ ├── Filters │ │ │ ├── EnumSchemaFilter.cs │ │ │ ├── Evaluation.cs │ │ │ ├── IdempotentAttribute.cs │ │ │ ├── IdempotentAttributeFilter.cs │ │ │ └── ValidationFilter.cs │ │ ├── Initializers │ │ │ ├── AppInitializer.cs │ │ │ └── ProductPromotionInitializer.cs │ │ ├── Middleware │ │ │ └── MiddlewareCache.cs │ │ ├── Migrations │ │ │ ├── 20250502133232_CatalogInitMigration.Designer.cs │ │ │ ├── 20250502133232_CatalogInitMigration.cs │ │ │ └── CatalogDbContextModelSnapshot.cs │ │ ├── Pipeline │ │ │ └── ValidatePipeline.cs │ │ └── ValidatorProvider │ │ │ ├── IValidatorProvider.cs │ │ │ └── ValidatorProvider.cs │ ├── Program.Testing.cs │ ├── Program.cs │ ├── Services │ │ ├── Authentication │ │ │ ├── AuthService.cs │ │ │ └── IAuthService.cs │ │ ├── FeatureToggle │ │ │ └── FeatureToggleService.cs │ │ └── Product │ │ │ └── ProductService.cs │ ├── Setup │ │ └── catalog.json │ ├── appsettings.Development.json │ ├── appsettings.Production.Test.json │ ├── appsettings.Production.json │ ├── appsettings.Test.json │ └── appsettings.json └── FeatureManagementFilters.http ├── tests ├── EventBus.Test │ ├── EventBus.Test.csproj │ ├── GlobalUsings.cs │ ├── RabbitMQEventBusTests.cs │ ├── RabbitMQFixture.cs │ ├── config │ │ └── rabbitmq.conf │ └── docker-compose.yml ├── FeatureFusion.ApiGateway.Test │ ├── FeatureFusion.ApiGateway.Test.csproj │ └── MemcachedFixedWindowRateLimiterTests.cs ├── FeatureFusion.Common │ ├── FeatureFusion.Common.csproj │ └── Shared │ │ └── MemcachedFixture.cs └── FeatureFusion.Test │ ├── FeatureFusion.Test.csproj │ ├── IdempotencyTest.cs │ ├── MediatorTest.cs │ └── MemcachedTest.cs └── waif-for-it.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behaviour, in case users don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files we want to always be normalized and converted to native line endings on checkout. 5 | *.sh text eol=lf 6 | 7 | # Declare files that will always have CRLF line endings on checkout. 8 | *.sln text eol=crlf 9 | *.csproj text eol=crlf 10 | 11 | # Denote all files that are truly binary and should not be modified. 12 | *.png -text 13 | *.jpg -text 14 | 15 | #remove UI libraries from the statistic 16 | src/Presentation/Nop.Web/wwwroot/lib/** linguist-vendored 17 | src/Presentation/Nop.Web/wwwroot/lib_npm/** linguist-vendored 18 | 19 | #specify the .cshtml files as HTML 20 | *.cshtml linguist-language=HTML -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Visual Studio 3 | ################# 4 | 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | 8 | # User-specific files 9 | *.suo 10 | *.user 11 | *.sln.docstates 12 | 13 | # Build results 14 | 15 | .vs/ 16 | [Dd]ebug/ 17 | [Rr]elease/ 18 | x64/ 19 | [Bb]in/ 20 | [Oo]bj/ 21 | net9.0/ 22 | # Build scripts folder 23 | [Aa]pp[Bb]uild/ 24 | [Oo]ut/ 25 | 26 | # Visual Studio Code files 27 | .vscode/ 28 | 29 | # MSTest test Results 30 | [Tt]est[Rr]esult*/ 31 | [Bb]uild[Ll]og.* 32 | 33 | *_i.c 34 | *_p.c 35 | *.ilk 36 | *.meta 37 | *.obj 38 | *.pch 39 | *.pdb 40 | *.pgc 41 | *.pgd 42 | *.rsp 43 | *.sbr 44 | *.tlb 45 | *.tli 46 | *.tlh 47 | *.tmp 48 | *.tmp_proj 49 | *.log 50 | *.vspscc 51 | *.vssscc 52 | .builds 53 | *.pidb 54 | *.log 55 | *.scc 56 | 57 | # Visual C++ cache files 58 | ipch/ 59 | *.aps 60 | *.ncb 61 | *.opensdf 62 | *.sdf 63 | *.cachefile 64 | 65 | # Visual Studio profiler 66 | *.psess 67 | *.vsp 68 | *.vspx 69 | 70 | # Guidance Automation Toolkit 71 | *.gpState 72 | 73 | # ReSharper is a .NET coding add-in 74 | _ReSharper*/ 75 | *.[Rr]e[Ss]harper 76 | 77 | # TeamCity is a build add-in 78 | _TeamCity* 79 | 80 | # DotCover is a Code Coverage Tool 81 | *.dotCover 82 | 83 | # NCrunch 84 | *.ncrunch* 85 | .*crunch*.local.xml 86 | 87 | # Installshield output folder 88 | [Ee]xpress/ 89 | 90 | # DocProject is a documentation generator add-in 91 | DocProject/buildhelp/ 92 | DocProject/Help/*.HxT 93 | DocProject/Help/*.HxC 94 | DocProject/Help/*.hhc 95 | DocProject/Help/*.hhk 96 | DocProject/Help/*.hhp 97 | DocProject/Help/Html2 98 | DocProject/Help/html 99 | 100 | # Click-Once directory 101 | publish/ 102 | 103 | # Publish Web Output 104 | *.Publish.xml 105 | *.pubxml 106 | 107 | #Launch settings 108 | **/Properties/launchSettings.json 109 | 110 | # NuGet Packages Directory 111 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 112 | #packages/ 113 | 114 | # Windows Azure Build Output 115 | csx 116 | *.build.csdef 117 | 118 | # Windows Store app package directory 119 | AppPackages/ 120 | 121 | # Others 122 | sql/ 123 | *.Cache 124 | ClientBin/ 125 | [Ss]tyle[Cc]op.* 126 | ~$* 127 | *~ 128 | *.dbmdl 129 | *.[Pp]ublish.xml 130 | *.pfx 131 | *.publishsettings 132 | 133 | # RIA/Silverlight projects 134 | Generated_Code/ 135 | 136 | # Backup & report files from converting an old project file to a newer 137 | # Visual Studio version. Backup files are not needed, because we have git ;-) 138 | _UpgradeReport_Files/ 139 | Backup*/ 140 | UpgradeLog*.XML 141 | UpgradeLog*.htm 142 | 143 | # SQL Server files 144 | App_Data/*.mdf 145 | App_Data/*.ldf 146 | 147 | ############# 148 | ## Windows detritus 149 | ############# 150 | 151 | # Windows image file caches 152 | Thumbs.db 153 | ehthumbs.db 154 | 155 | # Folder config file 156 | Desktop.ini 157 | 158 | # Recycle Bin used on file shares 159 | $RECYCLE.BIN/ 160 | 161 | # Mac crap 162 | .DS_Store 163 | 164 | 165 | ####################### 166 | ## project specific 167 | ########### 168 | glob:*.user 169 | *.patch 170 | *.hg 171 | 172 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 2 | WORKDIR /build 3 | 4 | COPY src/FeatureFusion/*.csproj ./src/FeatureFusion/ 5 | COPY src/EventBusRabbitMQ/*.csproj ./src/EventBusRabbitMQ/ 6 | COPY src/FeatureFusion.AppHost.ServiceDefaults/*.csproj ./src/FeatureFusion.AppHost.ServiceDefaults/ 7 | COPY tests/EventBus.Test/*.csproj ./tests/EventBus.Test/ 8 | 9 | # Integration/Functional tests on ci/pipelines 10 | RUN dotnet restore tests/EventBus.Test/EventBus.Test.csproj 11 | 12 | COPY ../ . 13 | 14 | WORKDIR /build/src/FeatureFusion 15 | RUN dotnet publish -c Release -o /out 16 | 17 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 18 | WORKDIR /app 19 | COPY --from=build /out ./ 20 | EXPOSE 5004 21 | HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:5004/health || exit 1 22 | ENTRYPOINT ["dotnet", "FeatureFusion.dll"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🚀 Advanced .NET Features Showcase 3 | 4 | ## Overview 5 | 6 | This repository is a **central hub** for all the advanced .NET and ASP.NET Core features I introduce on **LinkedIn**. It provides hands-on implementations of key concepts with real-world examples, allowing you to explore, test, and integrate them into your projects. 7 | 8 | ### 🔹 What's Inside? 9 | - ✅ **Feature Management & Feature Flags** 10 | - ✅ **Rabbitmq Eventbus Ready for Miroservices** 11 | - ✅ **Aspire Orchestration** 12 | - ✅ **Aspire App Host Integration Tests** 13 | - ✅ **IdempotentFusion (Idempotent Api with optional lock)** 14 | - ✅ **Reusable Generic Cursor Pagination** 15 | - ✅ **Manual Mediator with Pipeline Behavior** 16 | - ✅ **Api Versioning Strategies** 17 | - ✅ **Generic and Reusable Api Validations** 18 | - ✅ **Middleware Dynamic Caching** 19 | - ✅ **App Initializer** 20 | - ✅ **High-Performance Caching (Memcached, Redis, etc.)** 21 | - ✅ **Distributed Rate Limiting with YARP & Memcached** 22 | - ✅ **Advanced API Design & Middleware** 23 | - ✅ **Optimized Data Processing Techniques** 24 | - ✅ **Performance Improvements & Best Practices** 25 | - 🧩 **Design Patterns** 26 | 27 | Each feature is structured for **easy exploration** and **practical implementation**. 28 | Functional Tests need Docker installed and running. 29 | 30 | --- 31 | ## ✨ RabbitMQ EventBus with Transactional Outbox/Inbox,Dlq,Idempotency 32 | ### 🛠 Setup 33 | #### 1️⃣ Start Apphost 34 | ## ✨ Distributed Rate Limiting with YARP & Memcached 35 | 36 | This example demonstrates **distributed rate limiting** using **YARP (Yet Another Reverse Proxy)** and **Memcached**. This approach ensures that API rate limits are enforced consistently across distributed instances. 37 | 38 | ### 🛠 Setup 39 | #### 1️⃣ Start Apphost 40 | or 41 | #### 1️⃣ Start Memcached 42 | Run the following command to spin up Memcached: 43 | 44 | ``` 45 | docker-compose up -d 46 | ``` 47 | 48 | #### 2️⃣ Configure YARP Rate Limiting 49 | The **YARP reverse proxy** is configured to limit incoming requests based on **IP-based quotas stored in Memcached**. 50 | 51 | ### 📌 Example Rate Limit Policy 52 | - **100 requests per minute per IP** 53 | - Requests exceeding the limit receive a `429 Too Many Requests` response 54 | 55 | #### 3️⃣ Test the Rate Limiter 56 | Use a tool like **Postman** or **cURL** to send multiple requests: 57 | 58 | ``` 59 | curl -X GET http://localhost:5000/api/resource -H "Authorization: Bearer " 60 | ``` 61 | 62 | Once the limit is reached, you’ll receive: 63 | 64 | ```json 65 | { 66 | "error": "Too Many Requests" 67 | } 68 | ``` 69 | 70 | --- 71 | 72 | ## ✨ Advanced Feature Management with Feature Filters 73 | 74 | This example demonstrates **feature management in ASP.NET Core** using feature filters. Feature filters allow conditional feature toggling based on factors like user claims, enabling personalized experiences. 75 | 76 | ### 🛠 Setup 77 | #### 1️⃣ Start Apphost 78 | or 79 | #### 1️⃣ Start Memcached 80 | To enable the **Memcached-based feature toggle**, run: 81 | 82 | ``` 83 | docker-compose up -d 84 | ``` 85 | 86 | #### 2️⃣ Generate a JWT Token 87 | To test VIP-based feature toggling, generate a JWT token using the following credentials: 88 | - **Username**: `vipuser` 89 | - **Password**: `vippassword` 90 | 91 | #### 3️⃣ Send a Request 92 | Once you have the JWT token, call the API: 93 | 94 | ``` 95 | GET /custom-greeting 96 | Authorization: Bearer 97 | ``` 98 | 99 | --- 100 | 101 | ## 🧩 Design Patterns 102 | 103 | This section contains **various design patterns** implemented in .NET, showcasing real-world use cases and best practices. Some of the patterns you'll find include: 104 | 105 | - **Mediator Pattern** 106 | - **Adapter Pattern** 107 | - **Decorator Pattern** 108 | - **Singleton Pattern** 109 | - **Factory Pattern** 110 | - **Repository Pattern** 111 | - **Strategy Pattern** 112 | - **CoR Pattern** 113 | - **Observer Pattern** 114 | - **Dependency Injection** 115 | 116 | Each pattern is demonstrated with clear code examples and explanations of when and why to use them. 117 | 118 | --- 119 | 120 | ## 📌 Stay Updated 121 | I regularly share **new features** and **deep-dive explanations** on **[LinkedIn](https://www.linkedin.com/in/mhhoseini)**. Follow along to stay up to date! 122 | 123 | --- 124 | 125 | 🔹 **Contributions**: PRs and discussions are welcome! 126 | 127 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | memcached: 3 | image: memcached:alpine 4 | ports: 5 | - "11211:11211" 6 | healthcheck: 7 | test: ["CMD", "nc", "-z", "memcached", "11211"] 8 | interval: 5s 9 | timeout: 3s 10 | retries: 5 11 | 12 | redis: 13 | image: redis:latest 14 | ports: 15 | - "6379:6379" 16 | command: "redis-server" 17 | volumes: 18 | - redis_data:/data 19 | healthcheck: 20 | test: ["CMD", "redis-cli", "-h", "redis", "ping"] 21 | interval: 5s 22 | timeout: 3s 23 | retries: 5 24 | 25 | eventbus: 26 | image: rabbitmq:management 27 | hostname: eventbus 28 | container_name: eventbus 29 | networks: 30 | default: 31 | aliases: 32 | - rabbitmq 33 | - eventbus 34 | ports: 35 | - "5672:5672" 36 | - "15672:15672" 37 | environment: 38 | RABBITMQ_DEFAULT_USER: guest 39 | RABBITMQ_DEFAULT_PASS: guest 40 | RABBITMQ_DEFAULT_VHOST: / 41 | RABBITMQ_NODENAME: rabbit@eventbus 42 | RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-proto_dist inet_tcp" 43 | RABBITMQ_DEFAULT_PERMISSIONS: "guest . \".*\" \".*\"" 44 | 45 | healthcheck: 46 | test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] 47 | interval: 10s 48 | timeout: 5s 49 | retries: 5 50 | volumes: 51 | - rabbitmq-data:/var/lib/rabbitmq 52 | 53 | 54 | postgres: 55 | image: postgres:15-alpine 56 | hostname: postgres 57 | ports: 58 | - "5432:5432" 59 | environment: 60 | POSTGRES_USER: username 61 | POSTGRES_PASSWORD: password 62 | POSTGRES_DB: eventstore 63 | POSTGRES_HOST_AUTH_METHOD: scram-sha-256 64 | POSTGRES_INITDB_ARGS: >- 65 | --username=username 66 | --pwfile=<(echo "password") 67 | --auth-host=scram-sha-256 68 | --auth-local=scram-sha-256 69 | volumes: 70 | - postgres_data:/var/lib/postgresql/data 71 | healthcheck: 72 | test: ["CMD-SHELL", "pg_isready -h localhost -U username -d eventstore"] 73 | interval: 5s 74 | timeout: 5s 75 | retries: 10 76 | start_period: 10s 77 | 78 | pgadmin: 79 | image: dpage/pgadmin4 80 | ports: 81 | - "5050:80" 82 | environment: 83 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 84 | PGADMIN_DEFAULT_PASSWORD: admin 85 | depends_on: 86 | postgres: 87 | condition: service_healthy 88 | 89 | featurefusion-app: 90 | build: . 91 | depends_on: 92 | postgres: 93 | condition: service_healthy 94 | redis: 95 | condition: service_healthy 96 | eventbus: 97 | condition: service_healthy 98 | memcached: 99 | condition: service_healthy 100 | environment: 101 | - ASPNETCORE_ENVIRONMENT=Production 102 | - MEMCACHED__SERVER=memcached:11211 103 | command: > 104 | sh -c " 105 | while ! nc -z postgres 5432; do sleep 1; done && 106 | while ! nc -z redis 6379; do sleep 1; done && 107 | while ! nc -z eventbus 5672;do sleep 1; done && 108 | while ! nc -z memcached 11211; do sleep 1; done && 109 | echo 'All dependencies ready' && 110 | dotnet FeatureFusion.dll 111 | " 112 | 113 | volumes: 114 | redis_data: 115 | postgres_data: 116 | rabbitmq-data: -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Domain/Enums.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace EventBusRabbitMQ.Domain 8 | { 9 | public enum MessageStoreResult 10 | { 11 | Success, 12 | Duplicate, 13 | StorageFailed, 14 | NoSubscribers, 15 | } 16 | public enum ProcessingResult 17 | { 18 | Success, 19 | RetryLater, 20 | PermanentFailure, 21 | Duplicate 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Domain/Transaction.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel.DataAnnotations; 5 | using System.ComponentModel.DataAnnotations.Schema; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Text.Json; 9 | using System.Threading.Tasks; 10 | 11 | namespace EventBusRabbitMQ.Domain 12 | { 13 | public abstract class MessageBase 14 | { 15 | [Key] 16 | public Guid Id { get; set; } 17 | 18 | [Required, MaxLength(200)] 19 | public required string EventType { get; set; } 20 | 21 | [Required] 22 | public byte[]? Payload { get; set; } 23 | 24 | [Required] 25 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 26 | 27 | public DateTime? ProcessedAt { get; set; } 28 | public DateTime? CompletedAt { get; set; } 29 | 30 | [Required, MaxLength(20)] 31 | public MessageStatus Status { get; set; } 32 | 33 | [MaxLength(500)] 34 | public string? Error { get; set; } 35 | } 36 | 37 | public class OutboxMessage : MessageBase 38 | { 39 | public int RetryCount { get; set; } 40 | 41 | public IntegrationEvent? GetEvent(JsonSerializerOptions options) 42 | { 43 | try 44 | { 45 | return JsonSerializer.Deserialize(Payload, options); 46 | } 47 | catch 48 | { 49 | return null; 50 | } 51 | } 52 | } 53 | 54 | public class InboxMessage : MessageBase 55 | { 56 | [Required] 57 | [MaxLength(200)] 58 | public required string ServiceName { get; set; } 59 | 60 | public bool IsProcessed { get; set; } 61 | 62 | // PostgreSQL-specific concurrency token using xmin system column 63 | [Timestamp] 64 | [Column("xmin")] 65 | public uint RowVersion { get; set; } 66 | 67 | // Navigation property for subscriber tracking 68 | public ICollection Subscribers { get; set; } = new List(); 69 | 70 | 71 | public TEvent? GetEvent(JsonSerializerOptions options) where TEvent : IntegrationEvent 72 | { 73 | if (Payload == null) return null; 74 | return JsonSerializer.Deserialize(Payload, options); 75 | } 76 | } 77 | 78 | public class InboxSubscriber 79 | { 80 | [Key] 81 | public Guid Id { get; set; } 82 | 83 | [Required] 84 | [MaxLength(200)] 85 | public required string SubscriberName { get; set; } 86 | 87 | [Required] 88 | public Guid MessageId { get; set; } 89 | 90 | [ForeignKey("MessageId")] 91 | public required InboxMessage Message { get; set; } 92 | 93 | [Required] 94 | [MaxLength(20)] 95 | public MessageStatus Status { get; set; } = MessageStatus.Pending; 96 | 97 | public int Attempts { get; set; } 98 | 99 | public DateTime? LastAttemptedAt { get; set; } 100 | 101 | [MaxLength(500)] 102 | public string? Error { get; set; } 103 | 104 | [Timestamp] 105 | [Column("xmin")] 106 | public uint RowVersion { get; set; } 107 | } 108 | public class ProcessedMessage 109 | { 110 | [Key] 111 | public Guid Id { get; set; } 112 | public DateTime ProcessedAt { get; set; } 113 | } 114 | public enum MessageStatus 115 | { 116 | Pending, 117 | Processing, 118 | Processed, 119 | Failed, 120 | Archived 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/EventBus.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/EventBusOptions.cs: -------------------------------------------------------------------------------- 1 | namespace EventBusRabbitMQ 2 | { 3 | 4 | public class EventBusOptions 5 | { 6 | public bool EnableDeduplication { get; set; } = false; 7 | public required string SubscriptionClientName { get; set; } 8 | public int RetryCount { get; set; } = 10; 9 | public int MessageTTL { get; set; } = 86400000; // 24 hours in ms 10 | public int MaxRetryCount { get; set; } = 3; 11 | 12 | } 13 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Events/IAllowDirectFallback.cs: -------------------------------------------------------------------------------- 1 |  2 | // fallback marker 3 | public interface IAllowDirectFallback 4 | { 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Events/IIntegrationEventHandler.cs: -------------------------------------------------------------------------------- 1 | namespace EventBusRabbitMQ.Events; 2 | 3 | public interface IIntegrationEventHandler : IIntegrationEventHandler 4 | where TIntegrationEvent : IntegrationEvent 5 | { 6 | Task Handle(TIntegrationEvent @event); 7 | 8 | Task IIntegrationEventHandler.Handle( 9 | IntegrationEvent @event) => Handle((TIntegrationEvent)@event); 10 | } 11 | 12 | public interface IIntegrationEventHandler 13 | { 14 | Task Handle(IntegrationEvent @event); 15 | } 16 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Events/IntegrationEvent.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | 6 | namespace EventBusRabbitMQ.Events; 7 | 8 | public record IntegrationEvent 9 | { 10 | public IntegrationEvent() 11 | { 12 | Id = Guid.NewGuid(); 13 | CreationDate = DateTime.UtcNow; 14 | } 15 | 16 | [JsonInclude] 17 | public Guid Id { get; set; } 18 | 19 | [JsonInclude] 20 | public DateTime CreationDate { get; set; } 21 | } 22 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Extensions/EventBusExtensions.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using EventBusRabbitMQ.Infrastructure; 3 | using EventBusRabbitMQ.Infrastructure.Context; 4 | using EventBusRabbitMQ.Infrastructure.EventBus; 5 | using EventBusRabbitMQ.Infrastructure.Messaging; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.EntityFrameworkCore.Diagnostics; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Hosting; 11 | using Microsoft.Extensions.Logging; 12 | using RabbitMQ.Client; 13 | using System; 14 | 15 | namespace Microsoft.Extensions.DependencyInjection; 16 | 17 | public static class EventBusExtensions 18 | { 19 | 20 | public static IEventBusBuilder AddRabbitMqEventBus(this IHostApplicationBuilder builder, string connectionName) 21 | { 22 | ArgumentNullException.ThrowIfNull(builder); 23 | 24 | builder.AddRabbitMQClient(connectionName, configureConnectionFactory: factory => 25 | { 26 | (factory).DispatchConsumersAsync = true; 27 | }); 28 | 29 | builder.Services.AddHostedService>(); 30 | 31 | builder.Services.AddSingleton(); 32 | builder.Services.AddScoped(); 33 | builder.Services.AddSingleton(); 34 | 35 | builder.Services.AddScoped(); 36 | builder.Services.AddSingleton(); 37 | builder.Services.AddSingleton(sp => 38 | (EventBus)sp.GetRequiredService()); 39 | 40 | 41 | builder.Services.AddSingleton(); 42 | 43 | return new EventBusBuilder(builder.Services); 44 | 45 | } 46 | 47 | private class EventBusBuilder(IServiceCollection services) : IEventBusBuilder 48 | { 49 | public IServiceCollection Services => services; 50 | } 51 | public static IEventBusBuilder AddEventDbContext( 52 | this IEventBusBuilder builder, 53 | string connectionString) 54 | where TDbContext : DbContext , IEventStoreDbContext 55 | { 56 | ArgumentNullException.ThrowIfNull(builder); 57 | 58 | builder.Services.AddDbContext((serviceProvider, options) => 59 | { 60 | var context = serviceProvider.GetRequiredService(); 61 | options.UseNpgsql(context.Database.GetDbConnection()) 62 | .ConfigureWarnings(warnings => warnings.Ignore( 63 | RelationalEventId.PendingModelChangesWarning)); 64 | }); 65 | 66 | builder.Services.AddDbContextFactory((provider, options) => 67 | { 68 | 69 | options.UseNpgsql(connectionString) 70 | .ConfigureWarnings(warnings => 71 | warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); 72 | }, lifetime: ServiceLifetime.Scoped); 73 | 74 | builder.Services.AddScoped>(); 75 | 76 | builder.Services.AddNpgsqlDataSource(connectionString, dataSourceBuilder => 77 | { 78 | dataSourceBuilder.EnableParameterLogging(); 79 | }); 80 | 81 | return new EventBusBuilder(builder.Services); 82 | } 83 | 84 | // when eventstore dbset are not part of dbcontext 85 | public static IEventBusBuilder AddSeeder( 86 | this IEventBusBuilder builder, 87 | string connectionName) 88 | where TDbContext : DbContext, IEventStoreDbContext 89 | { 90 | builder.Services.AddHostedService(provider => 91 | { 92 | var logger = provider.GetRequiredService>(); 93 | return new DatabaseSeeder(provider, logger); 94 | }); 95 | 96 | return new EventBusBuilder(builder.Services); 97 | } 98 | 99 | public static IEventBusBuilder AddSubscription( 100 | this IEventBusBuilder builder) 101 | where TEvent : IntegrationEvent 102 | where THandler : class, IIntegrationEventHandler 103 | { 104 | builder.Services.AddKeyedTransient(typeof(TEvent)); 105 | builder.Services.Configure(o => 106 | { 107 | o.EventTypes[typeof(TEvent).Name] = typeof(TEvent); 108 | }); 109 | return builder; 110 | } 111 | } 112 | 113 | public interface IEventBusBuilder 114 | { 115 | IServiceCollection Services { get; } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Extensions/ModelBuilderExtension.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Domain; 2 | using Microsoft.EntityFrameworkCore; 3 | using System.Reflection.Emit; 4 | 5 | namespace EventBusRabbitMQ.Extensions 6 | { 7 | public static class ModelBuilderExtensions 8 | { 9 | public static void UseEventStore(this ModelBuilder builder) 10 | { 11 | builder.Entity(entity => 12 | { 13 | entity.ToTable("outbox_messages"); 14 | 15 | // Primary Key 16 | entity.HasKey(e => e.Id) 17 | .HasName("pk_outbox_messages"); 18 | entity.Property(e => e.Id) 19 | .HasColumnName("id") 20 | .ValueGeneratedNever(); 21 | 22 | // Column Configurations (PostgreSQL-optimized) 23 | entity.Property(e => e.EventType) 24 | .HasColumnName("event_type") 25 | .HasMaxLength(256) 26 | .IsRequired(); 27 | 28 | entity.Property(e => e.Status) 29 | .HasColumnName("status") 30 | .HasConversion() 31 | .HasMaxLength(20) 32 | .IsRequired(); 33 | 34 | entity.Property(e => e.CreatedAt) 35 | .HasColumnName("created_at") 36 | .IsRequired() 37 | .HasColumnType("timestamptz"); 38 | 39 | entity.Property(e => e.ProcessedAt) 40 | .HasColumnName("processed_at") 41 | .HasColumnType("timestamptz"); 42 | 43 | entity.Property(e => e.Payload) 44 | .HasColumnName("payload") 45 | .HasColumnType("jsonb") 46 | .IsRequired(); 47 | 48 | entity.Property(e => e.RetryCount) 49 | .HasColumnName("retry_count") 50 | .HasDefaultValue(0); 51 | 52 | // MAIN FILTERED INDEX 53 | entity.HasIndex(e => e.CreatedAt) 54 | .HasDatabaseName("ix_outbox_messages_unprocessed") 55 | .HasFilter("processed_at IS NULL") 56 | .IncludeProperties(e => new { e.Id, e.EventType, e.Payload }); 57 | 58 | entity.HasIndex(e => new { e.Status, e.RetryCount, e.CreatedAt }) 59 | .HasDatabaseName("ix_outbox_messages_status_retry_created") 60 | .HasFilter("status IN ('Pending', 'Failed')") 61 | .IncludeProperties(e => new { e.Id, e.EventType, e.Payload }); 62 | }); 63 | builder.Entity(entity => 64 | { 65 | entity.ToTable("inbox_messages"); 66 | entity.HasKey(e => e.Id); 67 | entity.Property(e => e.RowVersion) 68 | .IsRowVersion() 69 | .HasColumnName("xmin"); 70 | 71 | entity.Property(e => e.Id).ValueGeneratedNever(); 72 | entity.Property(e => e.EventType).HasMaxLength(256).IsRequired(); 73 | entity.Property(e => e.Status) 74 | .HasConversion() 75 | .HasMaxLength(20) 76 | .IsRequired(); 77 | entity.Property(e => e.CreatedAt).IsRequired(); 78 | entity.Property(e => e.Payload).IsRequired(); 79 | entity.Property(e => e.ProcessedAt).IsRequired(false); 80 | 81 | entity.HasMany(e => e.Subscribers) 82 | .WithOne(s => s.Message) 83 | .HasForeignKey(s => s.MessageId); 84 | 85 | entity.HasIndex(e => e.Id).IsUnique(); 86 | }); 87 | 88 | 89 | builder.Entity(entity => 90 | { 91 | entity.ToTable("inbox_subscribers"); 92 | entity.HasKey(e => e.Id); 93 | 94 | entity.Property(e => e.SubscriberName).HasMaxLength(256).IsRequired(); 95 | entity.Property(e => e.Status) 96 | .HasConversion() 97 | .HasMaxLength(20) 98 | .IsRequired(); 99 | entity.Property(e => e.Attempts).HasDefaultValue(0); 100 | entity.Property(e => e.LastAttemptedAt).IsRequired(false); 101 | entity.Property(e => e.Error).IsRequired(false); 102 | entity.HasIndex(e => new { e.MessageId, e.SubscriberName }).IsUnique(); 103 | entity.HasIndex(e => e.Status); 104 | }); 105 | 106 | builder.Entity(entity => 107 | { 108 | entity.ToTable("processed_messages"); 109 | }); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/DbContext/DbSeeder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | using System.Threading.Tasks; 7 | 8 | namespace EventBusRabbitMQ.Infrastructure.Context 9 | { 10 | public class DatabaseSeeder : IHostedService 11 | { 12 | private readonly IServiceProvider _serviceProvider; 13 | private readonly ILogger _logger; 14 | 15 | public DatabaseSeeder(IServiceProvider serviceProvider, ILogger logger) 16 | { 17 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 18 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 19 | } 20 | 21 | public async Task SeedAsync() 22 | { 23 | try 24 | { 25 | using (var scope = _serviceProvider.CreateScope()) 26 | { 27 | var dbContext = scope.ServiceProvider.GetRequiredService(); 28 | 29 | _logger.LogInformation("Ensuring the database and tables are created..."); 30 | await dbContext.Database.EnsureCreatedAsync(); 31 | 32 | } 33 | } 34 | catch (Exception ex) 35 | { 36 | _logger.LogError(ex, "An error occurred while ensuring the database and tables are created."); 37 | 38 | } 39 | } 40 | 41 | public Task StartAsync(CancellationToken cancellationToken) 42 | { 43 | return SeedAsync(); 44 | } 45 | 46 | public Task StopAsync(CancellationToken cancellationToken) 47 | { 48 | return Task.CompletedTask; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/DbContext/EventBusDbContext.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Domain; 2 | using EventBusRabbitMQ.Events; 3 | using EventBusRabbitMQ.Extensions; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Data.Common; 9 | using System.Linq; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | 13 | namespace EventBusRabbitMQ.Infrastructure.Context 14 | { 15 | 16 | public class EventBusDbContext : DbContext 17 | { 18 | public EventBusDbContext(DbContextOptions options) 19 | : base(options) 20 | { 21 | } 22 | 23 | public DbSet OutboxMessages { get; set; } 24 | public DbSet InboxMessages { get; set; } 25 | public DbSet ProcessedMessages { get; set; } 26 | public DbSet InboxSubscriber { get; set; } 27 | 28 | protected override void OnModelCreating(ModelBuilder modelBuilder) 29 | { 30 | base.OnModelCreating(modelBuilder); 31 | 32 | base.OnModelCreating(modelBuilder); 33 | 34 | modelBuilder.Entity(entity => 35 | { 36 | entity.ToTable("outbox_messages"); 37 | 38 | // Primary Key 39 | entity.HasKey(e => e.Id) 40 | .HasName("pk_outbox_messages"); 41 | entity.Property(e => e.Id) 42 | .HasColumnName("id") 43 | .ValueGeneratedNever(); 44 | 45 | entity.Property(e => e.EventType) 46 | .HasColumnName("event_type") 47 | .HasMaxLength(256) 48 | .IsRequired(); 49 | 50 | entity.Property(e => e.Status) 51 | .HasColumnName("status") 52 | .HasConversion() 53 | .HasMaxLength(20) 54 | .IsRequired(); 55 | 56 | entity.Property(e => e.CreatedAt) 57 | .HasColumnName("created_at") 58 | .IsRequired() 59 | .HasColumnType("timestamptz"); 60 | 61 | entity.Property(e => e.ProcessedAt) 62 | .HasColumnName("processed_at") 63 | .HasColumnType("timestamptz"); 64 | 65 | entity.Property(e => e.Payload) 66 | .HasColumnName("payload") 67 | .HasColumnType("jsonb") 68 | .IsRequired(); 69 | 70 | entity.Property(e => e.RetryCount) 71 | .HasColumnName("retry_count") 72 | .HasDefaultValue(0); 73 | 74 | 75 | entity.HasIndex(e => e.CreatedAt) 76 | .HasDatabaseName("ix_outbox_messages_unprocessed") 77 | .HasFilter("processed_at IS NULL") 78 | .IncludeProperties(e => new { e.Id, e.EventType, e.Payload }); 79 | entity.HasIndex(e => new { e.Status, e.RetryCount, e.CreatedAt }) 80 | .HasDatabaseName("ix_outbox_messages_status_retry_created") 81 | .HasFilter("status IN ('Pending', 'Failed')") 82 | .IncludeProperties(e => new { e.Id, e.EventType, e.Payload }); 83 | }); 84 | 85 | modelBuilder.Entity(entity => 86 | { 87 | entity.ToTable("inbox_messages"); 88 | entity.HasKey(e => e.Id); 89 | entity.Property(e => e.RowVersion) 90 | .IsRowVersion() 91 | .HasColumnName("xmin"); 92 | 93 | entity.Property(e => e.Id).ValueGeneratedNever(); 94 | entity.Property(e => e.EventType).HasMaxLength(256).IsRequired(); 95 | entity.Property(e => e.Status) 96 | .HasConversion() 97 | .HasMaxLength(20) 98 | .IsRequired(); 99 | entity.Property(e => e.CreatedAt).IsRequired(); 100 | entity.Property(e => e.Payload).IsRequired(); 101 | entity.Property(e => e.ProcessedAt).IsRequired(false); 102 | 103 | entity.HasMany(e => e.Subscribers) 104 | .WithOne(s => s.Message) 105 | .HasForeignKey(s => s.MessageId); 106 | 107 | entity.HasIndex(e => e.Id).IsUnique(); 108 | }); 109 | modelBuilder.Entity(entity => 110 | { 111 | entity.ToTable("inbox_subscribers"); 112 | entity.HasKey(e => e.Id); 113 | 114 | entity.Property(e => e.SubscriberName).HasMaxLength(256).IsRequired(); 115 | entity.Property(e => e.Status) 116 | .HasConversion() 117 | .HasMaxLength(20) 118 | .IsRequired(); 119 | entity.Property(e => e.Attempts).HasDefaultValue(0); 120 | entity.Property(e => e.LastAttemptedAt).IsRequired(false); 121 | entity.Property(e => e.Error).IsRequired(false); 122 | entity.HasIndex(e => new { e.MessageId, e.SubscriberName }).IsUnique(); 123 | entity.HasIndex(e => e.Status); 124 | }); 125 | modelBuilder.Entity(entity => 126 | { 127 | entity.ToTable("processed_messages"); 128 | }); 129 | base.OnModelCreating(modelBuilder); 130 | } 131 | } 132 | public interface IEventStoreDbContext 133 | { 134 | DbSet OutboxMessages { get; set; } 135 | DbSet InboxMessages { get; set; } 136 | DbSet ProcessedMessages { get; set; } 137 | DbSet InboxSubscriber { get; set; } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/EventBus/IEventBus.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using Microsoft.EntityFrameworkCore.Storage; 3 | using Microsoft.Extensions.Hosting; 4 | using RabbitMQ.Client; 5 | using System; 6 | 7 | namespace EventBusRabbitMQ.Infrastructure.EventBus 8 | { 9 | public partial interface IEventBus : IHostedService, IDisposable 10 | { 11 | /// 12 | /// Publishes an integration event to the message broker 13 | /// 14 | /// Type of the integration event 15 | /// The event to publish 16 | /// Whether to use the outbox pattern 17 | /// Cancellation token 18 | Task PublishAsync(TEvent @event, 19 | CancellationToken ct = default) 20 | where TEvent : IntegrationEvent; 21 | Task PublishAsync(TEvent @event, 22 | IDbContextTransaction transaction) 23 | where TEvent : IntegrationEvent; 24 | Task PublishDirect(TEvent @event, 25 | CancellationToken ct = default) 26 | where TEvent : IntegrationEvent; 27 | IModel? GetConsumerChannel(); 28 | Task ResetTopologyAsync(CancellationToken ct = default); 29 | Task ValidateTopologyAsync(CancellationToken ct = default); 30 | 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/EventBus/IRabbitMQPersistentConnection.cs: -------------------------------------------------------------------------------- 1 | using RabbitMQ.Client; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace EventBusRabbitMQ.Infrastructure.EventBus 9 | { 10 | public interface IRabbitMQPersistentConnection : IDisposable 11 | { 12 | bool IsConnected { get; } 13 | Task CreateModelAsync(CancellationToken cancellationToken = default); 14 | Task TryConnectAsync(CancellationToken cancellationToken = default); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/EventBus/ResiliencePipelineFactory.cs: -------------------------------------------------------------------------------- 1 |  2 | using EventBusRabbitMQ.Infrastructure; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using Polly; 7 | using Polly.CircuitBreaker; 8 | using Polly.Fallback; 9 | using Polly.Retry; 10 | using Polly.Timeout; 11 | using RabbitMQ.Client; 12 | using RabbitMQ.Client.Exceptions; 13 | using System; 14 | using System.Collections.Concurrent; 15 | using System.Net.Sockets; 16 | using System.Threading.Tasks; 17 | 18 | public interface IResiliencePipelineProvider 19 | { 20 | IAsyncPolicy GetConnectionPolicy(); 21 | IAsyncPolicy GetChannelPolicy(); 22 | IAsyncPolicy GetPublishingPolicy(); 23 | IAsyncPolicy GetConsumingPolicy(); 24 | } 25 | 26 | public class ResiliencePipelineFactory : IResiliencePipelineProvider 27 | { 28 | private readonly ResilienceOptions _options; 29 | private readonly ILogger _logger; 30 | private readonly ConcurrentDictionary _nonGenericCache; 31 | private readonly ConcurrentDictionary _genericCache; 32 | 33 | public ResiliencePipelineFactory( 34 | IOptions options, 35 | ILogger logger) 36 | { 37 | _options = options.Value; 38 | _logger = logger; 39 | _nonGenericCache = new ConcurrentDictionary(); 40 | _genericCache = new ConcurrentDictionary(); 41 | } 42 | 43 | public IAsyncPolicy GetConnectionPolicy() => 44 | GetOrCreateGenericPolicy("connection", CreateConnectionPolicy); 45 | 46 | public IAsyncPolicy GetChannelPolicy() => 47 | GetOrCreateGenericPolicy("channel", CreateChannelPolicy); 48 | 49 | public IAsyncPolicy GetPublishingPolicy() => 50 | _nonGenericCache.GetOrAdd("publish", _ => CreatePublishingPolicy()); 51 | 52 | public IAsyncPolicy GetConsumingPolicy() => 53 | _nonGenericCache.GetOrAdd("consume", _ => CreateConsumingPolicy()); 54 | 55 | 56 | private IAsyncPolicy GetOrCreateGenericPolicy(string policyKey, Func> policyFactory) 57 | { 58 | if (_genericCache.TryGetValue(policyKey, out var cachedPolicy) && cachedPolicy is IAsyncPolicy typedPolicy) 59 | return typedPolicy; 60 | 61 | var newPolicy = policyFactory() 62 | .WithPolicyKey(policyKey); 63 | 64 | _genericCache[policyKey] = newPolicy; 65 | return newPolicy; 66 | } 67 | 68 | private IAsyncPolicy CreateConnectionPolicy() 69 | { 70 | return Policy 71 | .Handle() 72 | .Or() 73 | .Or() 74 | .Or() 75 | .WaitAndRetryAsync( 76 | retryCount: _options.ConnectionRetryCount, 77 | sleepDurationProvider: attempt => CalculateBackoff(attempt), 78 | onRetry: (outcome, delay, retryCount, _) => 79 | { 80 | _logger.LogWarning("Connection retry {RetryCount} after {DelayMs}ms", 81 | retryCount, delay.TotalMilliseconds); 82 | }) 83 | .WrapAsync(Policy.TimeoutAsync( 84 | seconds: _options.ConnectionTimeoutSeconds, 85 | timeoutStrategy: TimeoutStrategy.Pessimistic)); 86 | } 87 | 88 | private IAsyncPolicy CreateChannelPolicy() 89 | { 90 | return Policy 91 | .Handle() 92 | .Or() 93 | .Or() 94 | .Or() 95 | .WaitAndRetryAsync( 96 | retryCount: _options.ChannelRetryCount, 97 | sleepDurationProvider: attempt => CalculateBackoff(attempt)); 98 | } 99 | 100 | private IAsyncPolicy CreatePublishingPolicy() 101 | { 102 | return Policy 103 | .Handle() 104 | .Or() 105 | .Or() 106 | .WaitAndRetryAsync( 107 | retryCount: _options.PublishRetryCount, 108 | sleepDurationProvider: attempt => CalculateBackoff(attempt)) 109 | .WrapAsync(Policy 110 | .Handle() 111 | .CircuitBreakerAsync( 112 | exceptionsAllowedBeforeBreaking: _options.CircuitBreakerThreshold, 113 | durationOfBreak: TimeSpan.FromSeconds(_options.CircuitBreakerDuration))); 114 | } 115 | 116 | private IAsyncPolicy CreateConsumingPolicy() 117 | { 118 | return Policy 119 | .Handle() 120 | .WaitAndRetryForeverAsync( 121 | sleepDurationProvider: attempt => CalculateBackoff(attempt)); 122 | } 123 | 124 | private TimeSpan CalculateBackoff(int attempt) 125 | { 126 | var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(attempt, 8))); 127 | var jitter = TimeSpan.FromMilliseconds(new Random().Next(0, 500)); 128 | return baseDelay + jitter; 129 | } 130 | } 131 | 132 | public class ResilienceOptions 133 | { 134 | public int ConnectionRetryCount { get; set; } = 5; 135 | public int ConnectionTimeoutSeconds { get; set; } = 30; 136 | public int ChannelRetryCount { get; set; } = 3; 137 | public int PublishRetryCount { get; set; } = 3; 138 | public int CircuitBreakerThreshold { get; set; } = 10; 139 | public int CircuitBreakerDuration { get; set; } = 30; 140 | } 141 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/EventBusSubscriptionInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization.Metadata; 4 | 5 | namespace EventBusRabbitMQ.Infrastructure; 6 | 7 | public class EventBusSubscriptionInfo 8 | { 9 | public ConcurrentDictionary EventTypes { get; } = []; 10 | 11 | public JsonSerializerOptions JsonSerializerOptions { get; } = new(DefaultSerializerOptions); 12 | 13 | internal static readonly JsonSerializerOptions DefaultSerializerOptions = new() 14 | { 15 | TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : JsonTypeInfoResolver.Combine() 16 | }; 17 | 18 | private static DefaultJsonTypeInfoResolver CreateDefaultTypeResolver() 19 | { 20 | return new DefaultJsonTypeInfoResolver(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/Messaging/IMessageDeduplicationService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Storage; 2 | using System.Data; 3 | 4 | namespace EventBusRabbitMQ.Infrastructure.Messaging 5 | { 6 | public interface IMessageDeduplicationService 7 | { 8 | Task IsDuplicateAsync(Guid messageId); 9 | Task MarkAsProcessedAsync(Guid messageId); 10 | } 11 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/Messaging/ITransactionalOutbox.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Domain; 2 | using EventBusRabbitMQ.Events; 3 | using EventBusRabbitMQ.Infrastructure.EventBus; 4 | using Microsoft.EntityFrameworkCore.Storage; 5 | 6 | namespace EventBusRabbitMQ.Infrastructure.Messaging 7 | { 8 | public interface ITransactionalOutbox : IAsyncDisposable 9 | { 10 | Task BeginTransactionAsync(); 11 | Task CommitAsync(); 12 | Task RollbackAsync(); 13 | Task IsDuplicateAsync(Guid messageId); 14 | Task StoreOutgoingMessageAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : IntegrationEvent; 15 | Task StoreOutgoingMessageAsync(TEvent @event, IDbContextTransaction ts) where TEvent : IntegrationEvent; 16 | Task StoreIncomingMessageAsync( 17 | Guid messageId, 18 | string eventType, 19 | byte[] payload, 20 | string serviceName); 21 | Task MarkMessageAsProcessedAsync(Guid messageId); 22 | Task UpdateHandlerStatuses(List<(string handlerType, ProcessingResult result, Guid messageID)> resultStatuses); 23 | } 24 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/Messaging/MessageDeduplicationService.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Domain; 2 | using EventBusRabbitMQ.Infrastructure.Context; 3 | using EventBusRabbitMQ.Infrastructure.Messaging; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Storage; 6 | using Microsoft.Extensions.Logging; 7 | 8 | 9 | public class MessageDeduplicationService : IMessageDeduplicationService 10 | { 11 | private readonly IDbContextFactory _contextFactory; 12 | private readonly TimeSpan _deduplicationWindow = TimeSpan.FromDays(1); 13 | private readonly ILogger _logger; 14 | 15 | public MessageDeduplicationService(IDbContextFactory contextFactory, ILogger logger) 16 | { 17 | _contextFactory = contextFactory; 18 | _logger = logger; 19 | } 20 | 21 | public async Task IsDuplicateAsync(Guid messageId) 22 | { 23 | await using var _dbContext = _contextFactory.CreateDbContext(); 24 | var cutoff = DateTime.UtcNow - _deduplicationWindow; 25 | 26 | return await _dbContext.ProcessedMessages 27 | .AsNoTracking() 28 | .AnyAsync(x => x.Id == messageId && x.ProcessedAt >= cutoff); 29 | } 30 | 31 | public async Task MarkAsProcessedAsync(Guid messageId) 32 | { 33 | await using var _dbContext = _contextFactory.CreateDbContext(); 34 | try 35 | { 36 | return await ResilientTransaction.New(_dbContext).ExecuteAsync(async () => 37 | { 38 | var exists = await _dbContext.ProcessedMessages 39 | .AsNoTracking() 40 | .AnyAsync(x => x.Id == messageId); 41 | 42 | if (exists) return false; 43 | 44 | _dbContext.ProcessedMessages.Add(new ProcessedMessage 45 | { 46 | Id = messageId, 47 | ProcessedAt = DateTime.UtcNow 48 | }); 49 | 50 | await _dbContext.SaveChangesAsync(); 51 | return true; 52 | }); 53 | } 54 | catch (Exception ex) 55 | { 56 | _logger.LogError(ex, "Failed to mark message {MessageId} as processed", messageId); 57 | throw; 58 | } 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/Messaging/ResilientTransaction.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | public class ResilientTransaction 4 | { 5 | private readonly DbContext _context; 6 | 7 | private ResilientTransaction(DbContext context) => 8 | _context = context ?? throw new ArgumentNullException(nameof(context)); 9 | 10 | public static ResilientTransaction New(DbContext context) => new(context); 11 | 12 | public async Task ExecuteAsync(Func action) 13 | { 14 | var strategy = _context.Database.CreateExecutionStrategy(); 15 | await strategy.ExecuteAsync(async () => 16 | { 17 | // Using NoTracking since we're just checking existence 18 | await using var transaction = await _context.Database.BeginTransactionAsync(); 19 | try 20 | { 21 | await action(); 22 | await transaction.CommitAsync(); 23 | } 24 | catch 25 | { 26 | await transaction.RollbackAsync(); 27 | throw; 28 | } 29 | }); 30 | } 31 | 32 | public async Task ExecuteAsync(Func> action) 33 | { 34 | var strategy = _context.Database.CreateExecutionStrategy(); 35 | return await strategy.ExecuteAsync(async () => 36 | { 37 | await using var transaction = await _context.Database.BeginTransactionAsync(); 38 | try 39 | { 40 | var result = await action(); 41 | await transaction.CommitAsync(); 42 | return result; 43 | } 44 | catch 45 | { 46 | await transaction.RollbackAsync(); 47 | throw; 48 | } 49 | }); 50 | } 51 | } -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/RabbitHealthCheck.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Infrastructure.EventBus; 2 | using Microsoft.Extensions.Diagnostics.HealthChecks; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace EventBusRabbitMQ.Infrastructure 10 | { 11 | internal class RabbitMQHealthCheck : IHealthCheck 12 | { 13 | private readonly IRabbitMQPersistentConnection _connection; 14 | 15 | public RabbitMQHealthCheck(IRabbitMQPersistentConnection connection) 16 | { 17 | _connection = connection; 18 | } 19 | 20 | public async Task CheckHealthAsync( 21 | HealthCheckContext context, 22 | CancellationToken cancellationToken = default) 23 | { 24 | try 25 | { 26 | if (!_connection.IsConnected) 27 | { 28 | await _connection.TryConnectAsync(cancellationToken); 29 | } 30 | 31 | return _connection.IsConnected 32 | ? HealthCheckResult.Healthy() 33 | : HealthCheckResult.Unhealthy(); 34 | } 35 | catch (Exception ex) 36 | { 37 | return HealthCheckResult.Unhealthy(exception: ex); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Infrastructure/RabbitMQConstants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace EventBusRabbitMQ.Infrastructure 8 | { 9 | public static class RabbitMQConstants 10 | { 11 | // Exchanges 12 | public const string MainExchangeName = "domain_events"; 13 | public const string DeadLetterExchangeName = "domain_events_dlx"; 14 | public const string OutboxExchangeName = "domain_events_outbox"; 15 | 16 | // Queue Settings 17 | public const int DefaultMessageTTL = 86400000; // 24 hours in ms 18 | public const int DefaultPrefetchCount = 10; 19 | 20 | // Message Properties 21 | public const byte PersistentDeliveryMode = 2; 22 | public static readonly TimeSpan DefaultConfirmTimeout = TimeSpan.FromSeconds(10); 23 | 24 | // Header Names 25 | public const string EventTypeHeader = "Event-Type"; 26 | public const string OccurredOnHeader = "Occurred-On"; 27 | public const string SourceServiceHeader = "Source-Service"; 28 | public const string MessageIdHeader = "Message-Id"; 29 | public const string RetryCountHeader = "Retry-Count"; 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/Utilities/MessageHelper.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using EventBusRabbitMQ.Infrastructure; 3 | using RabbitMQ.Client.Events; 4 | using RabbitMQ.Client; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | using Microsoft.EntityFrameworkCore; 11 | using Npgsql; 12 | 13 | namespace EventBusRabbitMQ.Utilities 14 | { 15 | public static class MessageHelper 16 | { 17 | public static void ConfigureBasicProperties( 18 | IBasicProperties properties, 19 | IntegrationEvent @event, 20 | string serviceName) 21 | { 22 | properties.DeliveryMode = RabbitMQConstants.PersistentDeliveryMode; 23 | properties.MessageId = @event.Id.ToString(); 24 | properties.Headers = new Dictionary 25 | { 26 | [RabbitMQConstants.EventTypeHeader] = @event.GetType().Name, 27 | [RabbitMQConstants.OccurredOnHeader] = @event.CreationDate.ToString("O"), 28 | [RabbitMQConstants.SourceServiceHeader] = serviceName, 29 | ["x-retry-count"] = 0 // Initialize retry counter 30 | }; 31 | } 32 | 33 | public static Guid GetMessageId(BasicDeliverEventArgs args) => 34 | Guid.Parse(args.BasicProperties.MessageId); 35 | 36 | public static int GetRetryCount(BasicDeliverEventArgs args) 37 | { 38 | if (args.BasicProperties.Headers?.TryGetValue("x-retry-count", out var value) == true) 39 | { 40 | return value is int count ? count : 0; 41 | } 42 | return 0; 43 | } 44 | 45 | public static void IncrementRetryCount(this IBasicProperties properties) 46 | { 47 | var current = GetRetryCount(properties); 48 | properties.Headers["x-retry-count"] = current + 1; 49 | } 50 | 51 | private static int GetRetryCount(IBasicProperties properties) 52 | { 53 | if (properties.Headers?.TryGetValue("x-retry-count", out var value) == true) 54 | { 55 | return value is int count ? count : 0; 56 | } 57 | return 0; 58 | } 59 | } 60 | public class MessageNotAckedException : Exception 61 | { 62 | public Guid MessageId { get; } 63 | 64 | public MessageNotAckedException(Guid messageId) 65 | : base($"Message {messageId} was not acknowledged by broker") 66 | { 67 | MessageId = messageId; 68 | } 69 | } 70 | public static class DbExceptionExtensions 71 | { 72 | public static bool IsDuplicateKeyError(this DbUpdateException ex) 73 | { 74 | return ex.InnerException is PostgresException pgEx && 75 | pgEx.SqlState == "23505"; // PostgreSQL duplicate key error code 76 | 77 | } 78 | } 79 | public class BusinessException : Exception 80 | { 81 | public BusinessException(string message) : base(message) { } 82 | } 83 | 84 | public class TransientException : Exception 85 | { 86 | public TransientException(string message) : base(message) { } 87 | public TransientException(string message, Exception inner) : base(message, inner) { } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EventBusRabbitMQ/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "RabbitMQ": { 3 | "Resilience": { 4 | "MaxRetryAttempts": 5, 5 | "FailureRatio": 0.3, 6 | "CircuitBreakDurationSeconds": 30, 7 | "TimeoutSeconds": 5 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/FeatureFusion.ApiGateway.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | true 18 | PreserveNewest 19 | 20 | 21 | PreserveNewest 22 | true 23 | PreserveNewest 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/FeatureFusion.ApiGateway.http: -------------------------------------------------------------------------------- 1 | @FeatureFusion.ApiGateway_HostAddress = http://localhost:5250 2 | 3 | GET {{FeatureFusion.ApiGateway_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/Program.cs: -------------------------------------------------------------------------------- 1 | using Enyim.Caching.Configuration; 2 | using FeatureFusion.ApiGateway.RateLimiter.Enums; 3 | using FeatureFusion.ApiGateway.RateLimiter.Enums.Extensions; 4 | using Microsoft.AspNetCore.Builder; 5 | using Yarp.ReverseProxy; // Add this using directive 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | #region Cache Provider 10 | 11 | var memcachedSection = builder.Configuration.GetSection("Memcached"); 12 | 13 | builder.Services 14 | .AddOptions() 15 | .Bind(memcachedSection) 16 | .ValidateDataAnnotations() 17 | .Validate(options => options.Servers?.Any() ?? false, "At least one Memcached server must be configured") 18 | .ValidateOnStart(); 19 | 20 | builder.Services.AddEnyimMemcached(); 21 | builder.Services.AddSingleton(); 22 | #endregion 23 | 24 | builder.Services.AddMemoryCache(); 25 | builder.Services.AddSingleton(); 26 | 27 | // Configure the rate limiter. 28 | builder.Services.AddRateLimiter(options => 29 | { 30 | // Switch to standard 429 response 31 | options.RejectionStatusCode = 429; 32 | 33 | options.AddPolicy( 34 | RateLimiterPolicy.MemcachedFixedWindow.GetDisplayName()); 35 | }); 36 | 37 | builder.Services.AddReverseProxy() 38 | .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); 39 | 40 | var app = builder.Build(); 41 | 42 | app.UseHttpsRedirection(); 43 | 44 | app.UseRateLimiter(); 45 | 46 | app.MapReverseProxy(); 47 | 48 | 49 | app.Run(); 50 | 51 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/Enums/Extensions/EnumExtension.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel; 3 | using System.Reflection; 4 | 5 | namespace FeatureFusion.ApiGateway.RateLimiter.Enums.Extensions 6 | { 7 | public static class EnumExtensions 8 | { 9 | public static string GetDisplayName(this Enum value) 10 | { 11 | return value.GetType() 12 | .GetMember(value.ToString()) 13 | .First() 14 | .GetCustomAttribute() 15 | ?.Name ?? value.ToString(); 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/Enums/RateLimiterPolicy.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace FeatureFusion.ApiGateway.RateLimiter.Enums 5 | { 6 | public enum RateLimiterPolicy 7 | { 8 | [Display(Name ="MemcachedFixedWindow")] 9 | MemcachedFixedWindow 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/MemcachedClientFactory.cs: -------------------------------------------------------------------------------- 1 | using Enyim.Caching; 2 | 3 | public interface IMemcachedClientFactory 4 | { 5 | IMemcachedClient CreateClient(); 6 | } 7 | 8 | public class MemcachedClientFactory : IMemcachedClientFactory 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | 12 | public MemcachedClientFactory(IServiceProvider serviceProvider) 13 | { 14 | _serviceProvider = serviceProvider; 15 | } 16 | 17 | public IMemcachedClient CreateClient() 18 | { 19 | return _serviceProvider.GetRequiredService(); 20 | } 21 | } -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/MemcachedFixedWindowRateLimiterOptions.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.ApiGateway.RateLimiter 2 | { 3 | 4 | /// 5 | /// Options for the Memcached fixed window rate limiter. 6 | /// 7 | public class MemcachedFixedWindowRateLimiterOptions 8 | { 9 | public int PermitLimit { get; set; } 10 | public TimeSpan Window { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/MemcachedRateLimiterPartition.cs: -------------------------------------------------------------------------------- 1 | using Enyim.Caching; 2 | using FeatureFusion.ApiGateway.RateLimiter; 3 | using Microsoft.Extensions.Caching.Memory; 4 | using System.Threading.RateLimiting; 5 | 6 | public static class MemcachedRateLimitPartition 7 | { 8 | public static RateLimitPartition GetFixedWindowRateLimiter( 9 | TKey partitionKey, 10 | Func factory, 11 | IMemcachedClient memcached, 12 | IMemoryCache memoryCache) 13 | { 14 | return RateLimitPartition.Get(partitionKey, key => 15 | new MemcachedFixedWindowRateLimiter(key, factory(key), memcached,memoryCache) 16 | ); 17 | } 18 | } -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/MemcachedRatelimiterPolicy.cs: -------------------------------------------------------------------------------- 1 | using Enyim.Caching; 2 | using FeatureFusion.ApiGateway.RateLimiter; 3 | using FeatureFusion.ApiGateway.RateLimiter.Enums; 4 | using Microsoft.AspNetCore.RateLimiting; 5 | using Microsoft.Extensions.Caching.Memory; 6 | using System.Threading.RateLimiting; 7 | 8 | public class MemcachedRateLimiterPolicy : IRateLimiterPolicy 9 | { 10 | private readonly IMemcachedClient _memcached; 11 | private readonly IMemoryCache _memoryCache; // for fallback on memcached failor 12 | 13 | public MemcachedRateLimiterPolicy(IMemcachedClient memcached 14 | ,IMemoryCache memoryCache) 15 | { 16 | _memcached = memcached ?? throw new ArgumentNullException(nameof(memcached)); 17 | _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); 18 | } 19 | 20 | public RateLimitPartition GetPartition(HttpContext httpContext) 21 | { 22 | // Creating the partition key using the policy name and client IP (It can be tenantId,userid,etc). 23 | //TODO: Configuration should be mapped from appsettings 24 | var partitionKey = $"{RateLimiterPolicy.MemcachedFixedWindow}-{httpContext.Connection.RemoteIpAddress}"; 25 | 26 | return MemcachedRateLimitPartition.GetFixedWindowRateLimiter( 27 | partitionKey: partitionKey, 28 | factory: _ => new MemcachedFixedWindowRateLimiterOptions 29 | { 30 | PermitLimit = 10, 31 | Window = TimeSpan.FromMinutes(1) 32 | }, 33 | memcached: _memcached, 34 | memoryCache: _memoryCache 35 | ); 36 | } 37 | public Func OnRejected 38 | { 39 | get => (context, cancellationToken) => 40 | { 41 | // Custom behavior when the rate limit is exceeded. 42 | context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; 43 | context.HttpContext.Response.WriteAsync("Too Many Requests. Please try again later.", cancellationToken); 44 | 45 | return ValueTask.CompletedTask; 46 | }; 47 | } 48 | } -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/RateLimitMetadata.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.RateLimiting; 2 | using Microsoft.AspNetCore.Http; 3 | namespace FeatureFusion.ApiGateway.RateLimiter 4 | { 5 | public static class RateLimitMetadataName 6 | { 7 | /// 8 | /// Indicates how long the user agent should wait before making a follow-up request (in seconds). 9 | /// For example, used in . 10 | /// 11 | public static MetadataName RetryAfter { get; } = MetadataName.Create("RATELIMIT_RETRYAFTER"); 12 | 13 | /// 14 | /// Request limit. For example, used in . 15 | /// Request limit per timespan. For example 100/30m, used in . 16 | /// 17 | public static MetadataName Limit { get; } = MetadataName.Create("RATELIMIT_LIMIT"); 18 | 19 | /// 20 | /// The number of requests left for the time window. 21 | /// For example, used in . 22 | /// 23 | public static MetadataName Remaining { get; } = MetadataName.Create("RATELIMIT_REMAINING"); 24 | 25 | /// 26 | /// The remaining window before the rate limit resets in seconds. 27 | /// For example, used in . 28 | /// 29 | public static MetadataName Reset { get; } = MetadataName.Create("RATELIMIT_RESET"); 30 | } 31 | } -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/RateLimiter/ResilencePolicy/AsyncPolicy.cs: -------------------------------------------------------------------------------- 1 | //using Polly; 2 | //using Polly.CircuitBreaker; 3 | //using Polly.Retry; 4 | 5 | 6 | //namespace FeatureFusion.ApiGateway.RateLimiter.ResilencePolicy 7 | //{ 8 | 9 | 10 | // // Define the retry policy with exponential backoff. 11 | // var retryPolicy = Policy 12 | // .Handle() // Handle all exceptions. 13 | // .WaitAndRetryAsync( 14 | // retryCount: 3, // Retry 3 times. 15 | // sleepDurationProvider: retryAttempt => 16 | // { 17 | // // Exponential backoff: 1 minute, 5 minutes, 10 minutes. 18 | // return TimeSpan.FromMinutes(Math.Pow(2, retryAttempt - 1)); 19 | // }, 20 | // onRetry: (exception, delay, retryCount, context) => 21 | // { 22 | // // Log the retry attempt. 23 | // Console.WriteLine($"Retry {retryCount}: Waiting {delay} before next retry. Error: {exception.Message}"); 24 | // } 25 | // ); 26 | 27 | // // Define the circuit breaker policy. 28 | // var circuitBreakerPolicy = Policy 29 | // .Handle() // Handle all exceptions. 30 | // .CircuitBreakerAsync( 31 | // exceptionsAllowedBeforeBreaking: 3, // Trip after 3 failures. 32 | // durationOfBreak: TimeSpan.FromMinutes(10) // Break for 10 minutes. 33 | // ); 34 | 35 | // // Combine the retry policy with the circuit breaker policy. 36 | // var resiliencePolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); 37 | //} 38 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/FeatureFusion.ApiGateway/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Memcached": { 3 | "Servers": [ 4 | { 5 | "Address": "localhost", 6 | "Port": 11211 7 | } 8 | ], 9 | "SocketPool": { 10 | "MinPoolSize": 5, 11 | "MaxPoolSize": 100, 12 | "ConnectionTimeout": "00:00:10" 13 | } 14 | }, 15 | "Logging": { 16 | "LogLevel": { 17 | "Default": "Information", 18 | "Microsoft.AspNetCore": "Warning", 19 | "Enyim.Caching": "Debug", // Detailed memcached operations 20 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 21 | } 22 | }, 23 | "RateLimiting": { 24 | "Policies": { 25 | "MemcachedFixedWindow": { 26 | "PermitLimit": 100, // Maximum number of requests allowed in a window 27 | "Window": "00:01:00", // Time window 28 | "PartitionKey": "client-ip" // Key to partition rate limits 29 | } 30 | } 31 | }, 32 | "ReverseProxy": { 33 | "Routes": { 34 | "route1": { 35 | "ClusterId": "cluster1", 36 | "RateLimiterPolicy": "MemcachedFixedWindow", // Rate limiting policy 37 | "Match": { 38 | "Hosts": [ "localhost" ] 39 | } 40 | } 41 | }, 42 | "Clusters": { 43 | "cluster1": { 44 | "LoadBalancingPolicy": "PowerOfTwoChoices", // Load balancing policy 45 | "Destinations": { 46 | "destination1": { 47 | "Address": "https://localhost:5001/" // First backend service 48 | }, 49 | "destination2": { 50 | "Address": "https://localhost:5002/" // Second backend service 51 | } 52 | } 53 | } 54 | } 55 | }, 56 | "AllowedHosts": "*" 57 | } -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.AppHost/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Aspire.Hosting.Lifecycle; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace FeatureFusion.AppHost 9 | { 10 | internal static class Extensions 11 | { 12 | /// 13 | /// Adds a hook to set the ASPNETCORE_FORWARDEDHEADERS_ENABLED environment variable to true for all projects in the application. 14 | /// 15 | public static IDistributedApplicationBuilder AddForwardedHeaders(this IDistributedApplicationBuilder builder) 16 | { 17 | builder.Services.TryAddLifecycleHook(); 18 | return builder; 19 | } 20 | 21 | private class AddForwardHeadersHook : IDistributedApplicationLifecycleHook 22 | { 23 | public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) 24 | { 25 | foreach (var p in appModel.GetProjectResources()) 26 | { 27 | p.Annotations.Add(new EnvironmentCallbackAnnotation(context => 28 | { 29 | context.EnvironmentVariables["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true"; 30 | })); 31 | } 32 | 33 | return Task.CompletedTask; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.AppHost/FeatureFusion.AppHost.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | Exe 7 | net9.0 8 | enable 9 | enable 10 | true 11 | de86e12e-e406-481d-b4d5-9320c016db3a 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.AppHost/Program.cs: -------------------------------------------------------------------------------- 1 | using Aspire.Hosting; 2 | using FeatureFusion.AppHost; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Aspire.StackExchange.Redis; 6 | 7 | var builder = DistributedApplication.CreateBuilder(args); 8 | 9 | builder.AddForwardedHeaders(); 10 | 11 | var memcached = builder.AddContainer("memcached","memcached","alpine") 12 | .WithEndpoint( 11211, targetPort: 11211, name: "memcached"); 13 | 14 | var redis = builder.AddRedis("redis") 15 | .WithEndpoint(6379, targetPort: 6379, name: "redis") 16 | .WithDataVolume("redis_data") 17 | .WithPersistence( 18 | interval: TimeSpan.FromMinutes(5), 19 | keysChangedThreshold: 100) 20 | .WithRedisInsight() 21 | .WithRedisCommander(); 22 | 23 | var rabbitMq = builder.AddRabbitMQ("eventbus") 24 | .WithEnvironment("RABBITMQ_LOGS", "-") 25 | .WithVolume("rabbitmq-data", "/var/lib/rabbitmq") 26 | .WithEndpoint(5672, targetPort: 5672, name: "amqp") 27 | .WithEndpoint(15672 ,targetPort: 15672, name: "management") 28 | .WithLifetime(ContainerLifetime.Persistent); 29 | 30 | var username = builder.AddParameter("username", secret: true, value: "username"); 31 | var password = builder.AddParameter("password", secret: true, value: "password"); 32 | var postgres = builder.AddPostgres(name:"postgres", userName:username, password:password) 33 | .WithPgAdmin(container => 34 | { 35 | container.WithEnvironment("PGADMIN_DEFAULT_EMAIL", "guest@admin.com"); 36 | container.WithEnvironment("PGADMIN_DEFAULT_PASSWORD", "guest"); 37 | }) 38 | .WithLifetime(ContainerLifetime.Persistent) 39 | .WithEndpoint (5432, targetPort: 5432, name: "postgres"); 40 | 41 | var catalogDb = postgres.AddDatabase("catalogdb"); 42 | 43 | builder.AddProject("featurefusion") 44 | .WithEndpoint(7762, targetPort: 5002, scheme: "https", name: "featurefusion-https") 45 | .WaitFor(memcached).WithEnvironment("Memcached__Servers__0__Address", "localhost") // we already used docker friendly connection string in appsettings 46 | .WaitFor(redis).WithEnvironment("Redis__ConnectionString", "localhost:6379") // we already used docker friendly connection string in appsettings 47 | .WithReference(rabbitMq).WaitFor(rabbitMq) 48 | .WithReference(catalogDb).WaitFor(catalogDb); 49 | 50 | try 51 | { 52 | builder.Build().Run(); 53 | } 54 | catch (Exception ex) 55 | { 56 | Console.WriteLine($"Application failed: {ex.Message}"); 57 | throw; 58 | } 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | }, 9 | //"RabbitMQ": { 10 | // "HostName": "eventbus", 11 | // "Port": 5672, 12 | // "UserName": "admin", 13 | // "Password": "admin123", 14 | // "VirtualHost": "/" 15 | //}, 16 | "ConnectionStrings": { 17 | "RabbitMQ": "amqp://admin:admin123@eventbus:5672/" 18 | } 19 | } -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "RabbitMQ": "amqp://admin:admin123@eventbus:5672/" 11 | } 12 | } -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.ServiceDefaults/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Diagnostics.HealthChecks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Diagnostics.HealthChecks; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.ServiceDiscovery; 7 | using OpenTelemetry; 8 | using OpenTelemetry.Metrics; 9 | using OpenTelemetry.Trace; 10 | 11 | namespace Microsoft.Extensions.Hosting; 12 | 13 | // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. 14 | // This project should be referenced by each service project in your solution. 15 | // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults 16 | public static class Extensions 17 | { 18 | public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder 19 | { 20 | builder.ConfigureOpenTelemetry(); 21 | 22 | builder.AddDefaultHealthChecks(); 23 | 24 | builder.Services.AddServiceDiscovery(); 25 | 26 | builder.Services.ConfigureHttpClientDefaults(http => 27 | { 28 | // Turn on resilience by default 29 | http.AddStandardResilienceHandler(); 30 | 31 | // Turn on service discovery by default 32 | http.AddServiceDiscovery(); 33 | }); 34 | 35 | // Uncomment the following to restrict the allowed schemes for service discovery. 36 | // builder.Services.Configure(options => 37 | // { 38 | // options.AllowedSchemes = ["https"]; 39 | // }); 40 | 41 | return builder; 42 | } 43 | 44 | public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder 45 | { 46 | builder.Logging.AddOpenTelemetry(logging => 47 | { 48 | logging.IncludeFormattedMessage = true; 49 | logging.IncludeScopes = true; 50 | }); 51 | 52 | builder.Services.AddOpenTelemetry() 53 | .WithMetrics(metrics => 54 | { 55 | metrics.AddAspNetCoreInstrumentation() 56 | .AddHttpClientInstrumentation() 57 | .AddRuntimeInstrumentation(); 58 | }) 59 | .WithTracing(tracing => 60 | { 61 | tracing.AddSource(builder.Environment.ApplicationName) 62 | .AddAspNetCoreInstrumentation() 63 | // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) 64 | //.AddGrpcClientInstrumentation() 65 | .AddHttpClientInstrumentation(); 66 | }); 67 | 68 | builder.AddOpenTelemetryExporters(); 69 | 70 | return builder; 71 | } 72 | 73 | private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder 74 | { 75 | var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); 76 | 77 | if (useOtlpExporter) 78 | { 79 | builder.Services.AddOpenTelemetry().UseOtlpExporter(); 80 | } 81 | 82 | // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) 83 | //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) 84 | //{ 85 | // builder.Services.AddOpenTelemetry() 86 | // .UseAzureMonitor(); 87 | //} 88 | 89 | return builder; 90 | } 91 | 92 | public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder 93 | { 94 | builder.Services.AddHealthChecks() 95 | // Add a default liveness check to ensure app is responsive 96 | .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); 97 | 98 | return builder; 99 | } 100 | 101 | public static WebApplication MapDefaultEndpoints(this WebApplication app) 102 | { 103 | // Adding health checks endpoints to applications in non-development environments has security implications. 104 | // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. 105 | if (app.Environment.IsDevelopment()) 106 | { 107 | // All health checks must pass for app to be considered ready to accept traffic after starting 108 | app.MapHealthChecks("/health"); 109 | 110 | // Only health checks tagged with the "live" tag must pass for app to be considered alive 111 | app.MapHealthChecks("/alive", new HealthCheckOptions 112 | { 113 | Predicate = r => r.Tags.Contains("live") 114 | }); 115 | } 116 | 117 | return app; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/FeatureFusion.AppHost.ServiceDefaults/FeatureFusion.AppHost.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/FeatureFusion/.http: -------------------------------------------------------------------------------- 1 | @ApiVersioningDemo_HostAddress = https://localhost:7226 2 | 3 | GET {{ApiVersioningDemo_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | 8 | GET {{ApiVersioningDemo_HostAddress}}/api/v1/greeting/custom-greeting 9 | 10 | ### 11 | -------------------------------------------------------------------------------- /src/FeatureFusion/Apis/MinimalApiGreeting.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning.Conventions; 2 | using FeatureFusion.Domain.Entities; 3 | using FeatureFusion.Dtos; 4 | using FeatureFusion.Infrastructure.Exetnsion; 5 | using FeatureManagementFilters.Services.ProductService; 6 | using FluentValidation; 7 | using Microsoft.AspNetCore.Http.HttpResults; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.FeatureManagement; 10 | using System; 11 | 12 | 13 | namespace FeatureManagementFilters.API.V2 14 | { 15 | public static class FeatureMinimalsApi 16 | { 17 | public static RouteGroupBuilder MapGreetingApiV2(this IEndpointRouteBuilder app) 18 | { 19 | // Create a version set 20 | var apiVersionSet = app.NewApiVersionSet() 21 | .HasApiVersion(2.0) 22 | .ReportApiVersions() 23 | .Build(); 24 | 25 | var api = app.MapGroup("api/v{version:apiVersion}") 26 | .WithApiVersionSet(apiVersionSet) 27 | .MapToApiVersion(2.0); 28 | 29 | api.MapGet("/product-promotion", GetProductPromotion); 30 | api.MapGet("/product-recommendation", GetProductRocemmendation); 31 | 32 | // to present manual Validation handling with dipendency injection 33 | api.MapPost("/minimal-custom-greeting", GetCustomGreeting) 34 | .Produces>() 35 | .ProducesValidationProblem() 36 | .Produces>(); 37 | 38 | 39 | // Approach 1: Using a generic endpoint filter with `AddEndpointFilter` 40 | // This approach applies validation by adding a generic endpoint filter to the route. 41 | api.MapPost("/person-endpointfilter", HandleCreatePerson) 42 | .AddEndpointFilter>(); 43 | 44 | // Approach 2: Using a generic endpoint extension method with `WithValidation` 45 | // This approach applies validation fluently using a custom extension method. 46 | api.MapPost("/person-builderextension", HandleCreatePerson) 47 | .WithValidation(); 48 | 49 | // Approach 3: Using a custom route handler builder extension 50 | // This approach encapsulates the endpoint definition and validation in a single method. 51 | api.MapPostWithValidation("/person-genericendpoint", HandleCreatePerson); 52 | 53 | 54 | return api; 55 | } 56 | 57 | public static async Task>, NotFound>> GetProductPromotion( 58 | IProductService productService, 59 | bool getFromMemCach = false) 60 | { 61 | try 62 | { 63 | var promotions = await productService.GetProductPromotionAsync(getFromMemCach); 64 | 65 | // Return TypedResults.Ok with a list of ProductPromotion 66 | return TypedResults.Ok(promotions); 67 | } 68 | catch 69 | { 70 | // Return TypedResults.NotFound with an error message 71 | return TypedResults.NotFound("An error occurred while fetching promotions."); 72 | } 73 | } 74 | 75 | public static async Task>, NotFound>> GetProductRocemmendation( 76 | IProductService productService) 77 | { 78 | try 79 | { 80 | var recommedation = await productService.GetProductRocemmendationAsync(); 81 | 82 | return TypedResults.Ok(recommedation); 83 | } 84 | catch 85 | { 86 | return TypedResults.NotFound("An error occurred while fetching promotions."); 87 | } 88 | } 89 | 90 | // 1- to present manual Validation handling with dipendency injection 91 | public static async Task, BadRequest, NotFound>> GetCustomGreeting 92 | ([AsParameters] GreetingDto greeting, 93 | GreetingValidator validator, 94 | IFeatureManager featureManager, 95 | ILogger _logger) 96 | { 97 | // Use the validator to validate the incoming model 98 | var validationResult = await validator.ValidateWithResultAsync(greeting); 99 | 100 | if (!validationResult.IsValid) 101 | { 102 | _logger.LogWarning("validation error on {GreetingType}: {Errors}", 103 | nameof(GreetingDto), validationResult.ProblemDetails!.Errors); 104 | 105 | // Return problem details if validation fails 106 | return TypedResults.BadRequest(validationResult.ProblemDetails); 107 | 108 | } 109 | 110 | if (await featureManager.IsEnabledAsync("CustomGreeting")) 111 | { 112 | return TypedResults.Ok($"Hello VIP user {greeting.Fullname}, this is your custom greeting V2!"); 113 | } 114 | 115 | return TypedResults.Ok("Hello Anonymous user!"); 116 | } 117 | 118 | 119 | // 2- to present dynamic Validation with generic endpoint filter 120 | public static async Task, BadRequest, NotFound>> HandleCreatePerson 121 | ([AsParameters] PersonDto person, 122 | IFeatureManager featureManager) 123 | { 124 | 125 | if (await featureManager.IsEnabledAsync("CustomGreeting")) 126 | { 127 | return TypedResults.Ok($"Hello VIP person {person.Name}, this is your custom greeting V2!"); 128 | } 129 | 130 | return TypedResults.Ok($"Hello Guest person! {person.Name}"); 131 | } 132 | 133 | // 3- to present dynamic Validation with generic ModelBinder 134 | public static async Task, BadRequest, NotFound>> HandleCreatePerson2 135 | ([AsParameters] PersonDto person, 136 | IFeatureManager featureManager) 137 | { 138 | 139 | if (await featureManager.IsEnabledAsync("CustomGreeting")) 140 | { 141 | return TypedResults.Ok($"Hello VIP person {person.Name}, this is your custom greeting V2!"); 142 | } 143 | 144 | return TypedResults.Ok($"Hello Guest person! {person.Name}"); 145 | } 146 | 147 | } 148 | } 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V1/Authentication.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureManagementFilters.Services.Authentication; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FeatureManagementFilters.Controllers.V1 6 | 7 | { 8 | //[ApiVersion("1.0")] 9 | [Route("api/v{version:apiVersion}/[controller]")] 10 | [ApiController] 11 | public class AuthController : ControllerBase 12 | { 13 | private readonly IAuthService _authService; 14 | 15 | public AuthController(IAuthService authService) 16 | { 17 | _authService = authService; 18 | } 19 | 20 | [HttpPost("login")] 21 | public IActionResult Login([FromBody] LoginDto login) 22 | { 23 | if (_authService.ValidateVipUser(login.Username, login.Password)) 24 | { 25 | var token = _authService.GenerateJwtToken(login.Username, isVip: true); 26 | return Ok(new { token }); 27 | } 28 | else 29 | { 30 | var token = _authService.GenerateJwtToken(login.Username, isVip: false); 31 | return Ok(new { token }); 32 | } 33 | 34 | 35 | } 36 | 37 | } 38 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V1/GreetingController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.FeatureManagement; 3 | 4 | namespace FeatureManagementFilters.Controllers.V1 5 | 6 | { 7 | //[ApiVersion("1.0")] 8 | [ApiController] 9 | [Route("api/v{version:apiVersion}/[controller]")] 10 | 11 | public class GreetingController : ControllerBase 12 | { 13 | private readonly IFeatureManagerSnapshot _featureManager; 14 | 15 | public GreetingController(IFeatureManagerSnapshot featureManager) 16 | { 17 | _featureManager = featureManager; 18 | } 19 | // [MapToApiVersion("1.0")] 20 | [HttpGet("custom-greeting")] 21 | public async Task GetCustomGreeting() 22 | { 23 | if (await _featureManager.IsEnabledAsync("CustomGreeting")) 24 | { 25 | return Ok("Hello VIP user, this is your custom greeting!"); 26 | } 27 | 28 | return Ok("Hello Anonymous user!"); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V2/Authentication.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureManagementFilters.Services.Authentication; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FeatureManagementFilters.Controllers.V2 6 | { 7 | //[ApiVersion("2.0")] 8 | [Route("api/v{version:apiVersion}/[controller]")] 9 | [ApiController] 10 | public class AuthController : ControllerBase 11 | { 12 | private readonly IAuthService _authService; 13 | 14 | public AuthController(IAuthService authService) 15 | { 16 | _authService = authService; 17 | } 18 | 19 | [HttpPost("login")] 20 | public IActionResult Login([FromBody] LoginDto login) 21 | { 22 | if (_authService.ValidateVipUser(login.Username, login.Password)) 23 | { 24 | var token = _authService.GenerateJwtToken(login.Username, isVip: true); 25 | return Ok(new { token }); 26 | } 27 | else 28 | { 29 | var token = _authService.GenerateJwtToken(login.Username, isVip: false); 30 | return Ok(new { token }); 31 | } 32 | 33 | 34 | } 35 | 36 | } 37 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V2/GreetingController.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Infrastructure.Filters; 3 | using FeatureFusion.Models; 4 | using FeatureManagementFilters.Models; 5 | using FeatureManagementFilters.Services.FeatureToggleService; 6 | using Microsoft.AspNetCore.Http.HttpResults; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.FeatureManagement; 9 | 10 | namespace FeatureFusion.Controllers.V2 11 | 12 | { 13 | 14 | [ApiController] 15 | [Route("api/v{version:apiVersion}/[controller]")] 16 | public class GreetingController : ControllerBase 17 | { 18 | private readonly IFeatureManagerSnapshot _featureManager; 19 | private readonly GreetingValidator _validator; 20 | private readonly IFeatureToggleService _featureToggleService; 21 | 22 | 23 | public GreetingController(IFeatureManagerSnapshot featureManager, 24 | GreetingValidator validator 25 | , IFeatureToggleService featureToggleService) 26 | { 27 | 28 | _featureManager = featureManager; 29 | _validator = validator; 30 | _featureToggleService = featureToggleService; 31 | } 32 | 33 | //leveraging coR pattern with some static rules 34 | [HttpPost("custom-greeting")] 35 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] // Ok 36 | [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ValidationProblemDetails))] // BadRequest 37 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(string))] // NotFound 38 | public async Task, BadRequest, NotFound>> GetCustomGreeting(GreetingDto greeting) 39 | { 40 | 41 | var validationResult = await _validator.ValidateWithResultAsync(greeting); 42 | 43 | if (!validationResult.IsValid) 44 | { 45 | 46 | return TypedResults.BadRequest(validationResult.ProblemDetails); 47 | } 48 | 49 | //for testing purpose - a static customer 50 | var user = new UserDto("Admin", true, false); 51 | 52 | bool greetingAccess = await _featureToggleService.CanAccessFeatureAsync(user); // ✅ Evaluates all rules 53 | 54 | if (greetingAccess) 55 | { 56 | return TypedResults.Ok($"Hello VIP user {greeting.Fullname}, this is your custom greeting V2!"); 57 | } 58 | 59 | return TypedResults.Ok("Hello Anonymous user V2!"); 60 | } 61 | 62 | } 63 | 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V2/OrderController.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.Filters; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using FeatureManagementFilters.Models; 4 | using FeatureManagementFilters.Services.FeatureToggleService; 5 | using Microsoft.AspNetCore.Http.HttpResults; 6 | using Microsoft.AspNetCore.Mvc; 7 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 8 | using FeatureFusion.Features.Orders.Commands; 9 | 10 | 11 | namespace FeatureFusion.Controllers.V2 12 | { 13 | [ApiController] 14 | [Route("api/v{version:apiVersion}/[controller]")] 15 | public class OrderController : Controller 16 | { 17 | private readonly OrderRequestValidator _validator; 18 | private readonly IMediator _mediator; 19 | public OrderController(OrderRequestValidator validator,IMediator mediator) 20 | { 21 | _validator = validator; 22 | _mediator = mediator; 23 | } 24 | 25 | // to test idempotent-filter , validation , mediator , rabbitmq 26 | [HttpPost("order")] 27 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(OrderResponse))] // Ok 28 | [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ValidationProblemDetails))] // BadRequest 29 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(string))] // NotFound 30 | [Idempotent(useLock: true)] // Apply the Idempotent attribute 31 | public async Task> CreateOrder([FromBody] CreateOrderCommand request) 32 | { 33 | // Validate the request 34 | var validationResult = await _validator.ValidateWithResultAsync(request); 35 | 36 | if (!validationResult.IsValid) 37 | { 38 | return BadRequest(validationResult.ProblemDetails); 39 | } 40 | 41 | var createOrderResult= await _mediator.Send(request); 42 | 43 | return Ok(createOrderResult); 44 | } 45 | 46 | } 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/FeatureFusion/Controllers/V2/ProductController.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.Filters; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using FeatureFusion.Models; 4 | using FeatureManagementFilters.Models; 5 | using FeatureManagementFilters.Services.FeatureToggleService; 6 | using Microsoft.AspNetCore.Http.HttpResults; 7 | using Microsoft.AspNetCore.Mvc; 8 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 9 | using FeatureFusion.Dtos; 10 | using FeatureFusion.Infrastructure.CursorPagination; 11 | using FeatureFusion.Dtos.Validator; 12 | using FluentValidation; 13 | using FeatureFusion.Features.Products.Queries; 14 | 15 | 16 | namespace FeatureFusion.Controllers.V2 17 | { 18 | [ApiController] 19 | [Route("api/v{version:apiVersion}/[controller]")] 20 | public class ProductController : Controller 21 | { 22 | private readonly GetProductsCommandValidator _validator; 23 | private readonly IMediator _mediator; 24 | public ProductController(GetProductsCommandValidator validator, IMediator mediator) 25 | { 26 | _validator = validator; 27 | _mediator = mediator; 28 | } 29 | 30 | // with cursor-pagination 31 | [HttpPost("products")] 32 | [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] 33 | [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] 34 | [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] 35 | public async Task>, 36 | BadRequest, ProblemHttpResult>> GetProducts( 37 | [FromQuery] GetProductsQuery command, 38 | CancellationToken cancellationToken) 39 | { 40 | { 41 | var validationResult = await _validator.ValidateWithResultAsync(command); 42 | if (validationResult.HasErrors()) 43 | { 44 | return TypedResults.BadRequest(validationResult.ProblemDetails); 45 | } 46 | 47 | var result = await _mediator.Send(command, cancellationToken); 48 | 49 | return result.ToHttpResult(); 50 | } 51 | } 52 | 53 | } 54 | 55 | public static class ResultExtensions 56 | { 57 | public static Results, BadRequest, ProblemHttpResult> ToHttpResult(this Result result) 58 | { 59 | return result.Match, BadRequest, ProblemHttpResult>>( 60 | success => TypedResults.Ok(success), 61 | (error, statusCode) => 62 | { 63 | var errors = new Dictionary 64 | { 65 | { "General", new[] { error } } 66 | }; 67 | return TypedResults.BadRequest(new ValidationProblemDetails(errors) 68 | { 69 | Title = "Request Error", 70 | Detail = error, 71 | Status = statusCode 72 | }); 73 | }); 74 | } 75 | } 76 | } 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/FeatureFusion/Domain/Entities/BaseEntitiy.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace FeatureFusion.Domain.Entities 4 | { 5 | public abstract partial record BaseEntity 6 | { 7 | [Key] 8 | public int Id { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FeatureFusion/Domain/Entities/Person.cs: -------------------------------------------------------------------------------- 1 | using FeatureManagementFilters.Models.Validator; 2 | using FluentValidation; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System; 5 | namespace FeatureFusion.Domain.Entities 6 | { 7 | public record Person : BaseEntity 8 | { 9 | public string Name { get; set; } 10 | public int Age { get; set; } 11 | }; 12 | public class PersonValidator : AbstractValidator 13 | { 14 | public PersonValidator() 15 | { 16 | RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); 17 | RuleFor(x => x.Age).InclusiveBetween(18, 99).WithMessage("Age must be between 18 and 99."); 18 | } 19 | 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/FeatureFusion/Domain/Entities/Product.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace FeatureFusion.Domain.Entities 4 | { 5 | public record Product : BaseEntity 6 | { 7 | public string Name { get; init; } 8 | public string FullDescription { get; set; } 9 | public bool Published { get; init; } 10 | public bool Deleted { get; init; } 11 | public bool VisibleIndividually { get; init; } 12 | public decimal Price { get; init; } 13 | public DateTime CreatedAt { get; init; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/FeatureFusion/Domain/Entities/ProductManufacturer.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Domain.Entities 2 | { 3 | public record ProductManufacturer 4 | { 5 | public int ProductId { get; init; } 6 | public int ManufacturerId { get; init; } 7 | public bool IsFeaturedProduct { get; init; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/GreetingDto.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace FeatureFusion.Dtos 4 | { 5 | public class GreetingDto 6 | { 7 | [FromHeader] 8 | public required string Fullname { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/LoginDto.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Dtos; 2 | public class LoginDto 3 | { 4 | public string Username { get; set; } 5 | public string Password { get; set; } 6 | } 7 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/PersonDto.cs: -------------------------------------------------------------------------------- 1 | using FeatureManagementFilters.Models.Validator; 2 | using FluentValidation; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System; 5 | namespace FeatureFusion.Dtos 6 | { 7 | public record PersonDto 8 | { 9 | public string Name { get; set; } 10 | public int Age { get; set; } 11 | }; 12 | public class PersonDtoValidator : AbstractValidator 13 | { 14 | public PersonDtoValidator() 15 | { 16 | RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); 17 | RuleFor(x => x.Age).InclusiveBetween(18, 99).WithMessage("Age must be between 18 and 99."); 18 | } 19 | 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/ProductDto.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Dtos 2 | { 3 | public record ProductDto( 4 | int Id, 5 | string Name, 6 | decimal Price, 7 | string Description, 8 | DateTime CreatedAt); 9 | } 10 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/ProductPromotionDto.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace FeatureFusion.Dtos 4 | { 5 | public record ProductPromotionDto 6 | { 7 | [JsonPropertyName("product_id")] 8 | public int ProductId { get; init; } 9 | [JsonPropertyName("product_name")] 10 | public string Name { get; init; } 11 | [JsonPropertyName("manufacturer_id")] 12 | public int ManufacturerId { get; init; } 13 | [JsonPropertyName("is_featured")] 14 | public bool IsFeatured { get; init; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/UserDto.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Dtos 2 | { 3 | public class UserDto 4 | { 5 | public string Id { get; set; } = Guid.NewGuid().ToString(); 6 | public string Role { get; set; } = "User"; // Default role 7 | public bool HasActiveSubscription { get; set; } = false; 8 | public bool IsBetaTester { get; set; } = false; 9 | 10 | public UserDto(string role, bool hasSubscription, bool isBetaTester) 11 | { 12 | Role = role; 13 | HasActiveSubscription = hasSubscription; 14 | IsBetaTester = isBetaTester; 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/Validator/BaseValidator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace FeatureManagementFilters.Models.Validator 4 | { 5 | public abstract class BaseValidator : AbstractValidator where TModel : class 6 | { 7 | protected BaseValidator() 8 | { 9 | PostInitialize(); 10 | } 11 | /// 12 | /// you can override this method in custom partial classes in order to add some custom initialization code to constructors 13 | /// 14 | protected virtual void PostInitialize() 15 | { 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/Validator/GetProductsCommandValidator.cs: -------------------------------------------------------------------------------- 1 |  2 | using FeatureFusion.Domain.Entities; 3 | using FeatureFusion.Features.Products.Queries; 4 | using FeatureFusion.Infrastructure.CursorPagination; 5 | using FluentValidation; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace FeatureFusion.Dtos.Validator 9 | { 10 | public sealed class GetProductsCommandValidator : AbstractValidator 11 | { 12 | public GetProductsCommandValidator() 13 | { 14 | RuleFor(x => x.Limit) 15 | .InclusiveBetween(1, 100) 16 | .WithMessage("Limit must be between 1 and 100"); 17 | 18 | RuleFor(x => x.SortBy) 19 | .IsInEnum() 20 | .WithMessage("Invalid sort field"); 21 | 22 | RuleFor(x => x.SortDirection) 23 | .IsInEnum() 24 | .WithMessage("Invalid sort direction"); 25 | 26 | RuleFor(x => x.Cursor) 27 | .Must(BeValidCursor) 28 | .When(x => !string.IsNullOrEmpty(x.Cursor)) 29 | .WithMessage("Invalid cursor format") 30 | .DependentRules(() => 31 | { 32 | RuleFor(x => x) 33 | .Must(BeCursorConsistentWithSort) 34 | .WithMessage("Cursor sort field doesn't match requested sort field"); 35 | }); 36 | } 37 | 38 | private static bool BeValidCursor(string cursor) 39 | { 40 | if (string.IsNullOrEmpty(cursor)) return true; 41 | 42 | try 43 | { 44 | var cursorData = CursorFactory.Decode(cursor); 45 | return cursorData != null && 46 | !string.IsNullOrEmpty(cursorData.SortBy) && 47 | cursorData.LastValue != null; 48 | } 49 | catch 50 | { 51 | return false; 52 | } 53 | } 54 | 55 | private static bool BeCursorConsistentWithSort(GetProductsQuery command) 56 | { 57 | if (string.IsNullOrEmpty(command.Cursor)) return true; 58 | 59 | var cursorData = CursorFactory.Decode(command.Cursor); 60 | if (cursorData == null) return false; 61 | 62 | var expectedSortBy = command.SortBy switch 63 | { 64 | ProductSortField.Id => nameof(Product.Id), 65 | ProductSortField.Name => nameof(Product.Name), 66 | ProductSortField.Price => nameof(Product.Price), 67 | ProductSortField.CreatedAt => nameof(Product.CreatedAt), 68 | _ => throw new ArgumentOutOfRangeException() 69 | }; 70 | 71 | return cursorData.SortBy.Equals(expectedSortBy, StringComparison.Ordinal); 72 | } 73 | public async Task ValidateWithResultAsync(GetProductsQuery item) 74 | { 75 | var validationResult = await ValidateAsync(item); 76 | 77 | if (!validationResult.IsValid) 78 | { 79 | var validationErrors = validationResult.Errors 80 | .GroupBy(e => e.PropertyName) 81 | .ToDictionary( 82 | group => group.Key, 83 | group => group.Select(e => e.ErrorMessage).ToArray() 84 | ); 85 | 86 | var problemDetails = new ValidationProblemDetails(validationErrors) 87 | { 88 | Status = StatusCodes.Status400BadRequest, 89 | Title = "One or more validation errors occurred.", 90 | Errors = validationErrors 91 | }; 92 | 93 | return ValidationResult.Failure(problemDetails); 94 | } 95 | 96 | return ValidationResult.Success(); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/Validator/GreetingValidator.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Models; 3 | using FeatureManagementFilters.Models.Validator; 4 | using FluentValidation; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | 8 | 9 | public class GreetingValidator : BaseValidator 10 | { 11 | private readonly ILogger _logger; 12 | 13 | public GreetingValidator(ILogger logger) 14 | { 15 | _logger = logger; 16 | RuleFor(x => x.Fullname) 17 | .NotEmpty().WithMessage("FullName is required.") 18 | .NotNull().WithMessage("FullName is required.") 19 | .Length(1, 100).WithMessage("FullName must be between 1 and 100 characters."); 20 | 21 | } 22 | 23 | public async Task ValidateWithResultAsync(GreetingDto item) 24 | { 25 | var validationResult = await ValidateAsync(item); 26 | 27 | if (!validationResult.IsValid) 28 | { 29 | var validationErrors = validationResult.Errors 30 | .GroupBy(e => e.PropertyName) 31 | .ToDictionary( 32 | group => group.Key, 33 | group => group.Select(e => e.ErrorMessage).ToArray() 34 | ); 35 | 36 | _logger.LogError($"validation error on {nameof(GreetingDto)}: {validationErrors}"); 37 | 38 | var problemDetails = new ValidationProblemDetails 39 | { 40 | Status = StatusCodes.Status400BadRequest, 41 | Title = "One or more validation errors occurred.", 42 | Errors = validationErrors 43 | }; 44 | 45 | return ValidationResult.Failure(problemDetails); 46 | } 47 | 48 | return ValidationResult.Success(); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/Validator/OrderRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Models.Validator 2 | { 3 | public class OrderRequestValidator 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/FeatureFusion/Dtos/Validator/ValidationResultWrapper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | public class ValidationResult 4 | { 5 | public bool IsValid { get; } 6 | public ValidationProblemDetails ProblemDetails { get; } 7 | 8 | private ValidationResult(bool isValid, ValidationProblemDetails problemDetails = null) 9 | { 10 | IsValid = isValid; 11 | ProblemDetails = problemDetails; 12 | } 13 | 14 | public static ValidationResult Success() => 15 | new ValidationResult(true); 16 | 17 | public static ValidationResult Failure(ValidationProblemDetails problemDetails) 18 | { 19 | ArgumentNullException.ThrowIfNull(problemDetails); 20 | 21 | return new ValidationResult(false, problemDetails); 22 | } 23 | 24 | public bool HasErrors() => !IsValid && ProblemDetails != null; 25 | } -------------------------------------------------------------------------------- /src/FeatureFusion/FeatureFusion.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | disable 6 | enable 7 | 13 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | all 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Behavior/LoggingBehavior.cs: -------------------------------------------------------------------------------- 1 | //namespace FeatureFusion.Features.Order.Behavior 2 | //{ 3 | // using System; 4 | // using System.Threading; 5 | // using System.Threading.Tasks; 6 | // using FeatureFusion.Infrastructure.CQRS; 7 | // using static FeatureFusion.Infrastructure.CQRS.Mediator; 8 | 9 | // public class LoggingBehavior : IPipelineBehavior where TRequest : IRequest 10 | // { 11 | // private readonly ILogger> _logger; 12 | 13 | // public LoggingBehavior(ILogger> logger) 14 | // { 15 | // _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 16 | // } 17 | 18 | // public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 19 | // { 20 | // // Log the incoming request (before handling) 21 | // _logger.LogInformation("Handling request of type {RequestType} with data: {RequestData}", 22 | // typeof(TRequest).Name, request); 23 | 24 | // // Call the next behavior/handler in the pipeline 25 | // var response = await next(cancellationToken); 26 | 27 | // // Log the response (after handling) 28 | // _logger.LogInformation("Handled request of type {RequestType} with response: {ResponseData}", 29 | // typeof(TRequest).Name, response); 30 | 31 | // return response; 32 | // } 33 | // } 34 | //} 35 | 36 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Behavior/TelemetryBehavior.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using Microsoft.Extensions.Logging; 4 | using static FeatureFusion.Infrastructure.CQRS.Mediator; 5 | 6 | namespace FeatureFusion.Features.Orders.Behavior 7 | { 8 | public class TelemetryBehavior : IPipelineBehavior 9 | where TRequest : IRequest 10 | { 11 | private readonly ILogger> _logger; 12 | 13 | public TelemetryBehavior(ILogger> logger) 14 | { 15 | _logger = logger; 16 | } 17 | 18 | public async Task Handle(TRequest request, 19 | RequestHandlerDelegate next, 20 | CancellationToken cancellationToken = default) 21 | { 22 | var stopwatch = Stopwatch.StartNew(); 23 | var activityName = $"{typeof(TRequest).Name} Handling"; 24 | 25 | using var activity = new Activity(activityName); 26 | activity.SetTag("request.name", typeof(TRequest).Name); 27 | activity.SetTag("request.type", typeof(TRequest).FullName); 28 | activity.Start(); 29 | 30 | try 31 | { 32 | _logger.LogInformation("Handling request {RequestType}", typeof(TRequest).Name); 33 | var response = await next(cancellationToken); 34 | stopwatch.Stop(); 35 | 36 | _logger.LogInformation("Handled {RequestType} in {ElapsedMs} ms", 37 | typeof(TRequest).Name, stopwatch.ElapsedMilliseconds); 38 | 39 | activity.SetTag("request.success", true); 40 | activity.SetTag("request.duration_ms", stopwatch.ElapsedMilliseconds); 41 | 42 | return response; 43 | } 44 | catch (Exception ex) 45 | { 46 | stopwatch.Stop(); 47 | _logger.LogError(ex, "Error handling {RequestType}: {ErrorMessage}", typeof(TRequest).Name, ex.Message); 48 | 49 | activity.SetTag("request.success", false); 50 | activity.SetTag("error.message", ex.Message); 51 | activity.SetTag("error.stacktrace", ex.StackTrace); 52 | activity.SetStatus(ActivityStatusCode.Error, ex.Message); 53 | 54 | throw; 55 | } 56 | finally 57 | { 58 | activity.Stop(); 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Commands/CreateOrderCommand.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using FeatureManagementFilters.Models.Validator; 4 | using FluentValidation; 5 | using Microsoft.AspNetCore.Mvc; 6 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 7 | 8 | 9 | namespace FeatureFusion.Features.Orders.Commands 10 | { 11 | public class CreateOrderCommand : IRequest> 12 | { 13 | public int ProductId { get; set; } 14 | public int Quantity { get; set; } 15 | public int CustomerId { get; set; } 16 | } 17 | public class OrderRequestValidator : BaseValidator 18 | { 19 | private readonly ILogger _logger; 20 | 21 | public OrderRequestValidator(ILogger logger) 22 | { 23 | _logger = logger; 24 | RuleFor(x => x.Quantity) 25 | .GreaterThan(0).WithMessage("Quantity must be greater than 0"); 26 | } 27 | public async Task ValidateWithResultAsync(CreateOrderCommand item) 28 | { 29 | var validationResult = await ValidateAsync(item); 30 | 31 | if (!validationResult.IsValid) 32 | { 33 | var validationErrors = validationResult.Errors 34 | .GroupBy(e => e.PropertyName) 35 | .ToDictionary( 36 | group => group.Key, 37 | group => group.Select(e => e.ErrorMessage).ToArray() 38 | ); 39 | 40 | _logger.LogError($"validation error on {nameof(GreetingDto)}: {validationErrors}"); 41 | 42 | var problemDetails = new ValidationProblemDetails 43 | { 44 | Status = StatusCodes.Status400BadRequest, 45 | Title = "One or more validation errors occurred.", 46 | Errors = validationErrors 47 | }; 48 | 49 | return ValidationResult.Failure(problemDetails); 50 | } 51 | 52 | return ValidationResult.Success(); 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Commands/CreateOrderCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.CQRS; 2 | using FeatureManagementFilters.Models; 3 | using static FeatureFusion.Controllers.V2.OrderController; 4 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 5 | 6 | using FeatureFusion.Features.Order.IntegrationEvents; 7 | using FeatureFusion.Features.Order.IntegrationEvents.Events; 8 | using FeatureFusion.Infrastructure.Context; 9 | using FeatureFusion.Domain.Entities; 10 | 11 | namespace FeatureFusion.Features.Orders.Commands 12 | { 13 | public class CreateOrderCommandHandler : IRequestHandler> 14 | { 15 | private readonly IServiceProvider _serviceProvider; 16 | private readonly CatalogDbContext _catalogDbContext; 17 | 18 | public CreateOrderCommandHandler(IServiceProvider serviceProvider, 19 | CatalogDbContext catalogdbContext) 20 | { 21 | _serviceProvider = serviceProvider; 22 | _catalogDbContext = catalogdbContext; 23 | } 24 | 25 | public async Task> Handle(CreateOrderCommand request, CancellationToken cancellationToken) 26 | { 27 | 28 | // Static in-memory product 29 | var product = new Product 30 | { 31 | Name = "Smartphone", 32 | Published = true, 33 | Deleted = false, 34 | VisibleIndividually = true, 35 | Price = 599.99m 36 | }; 37 | 38 | // Static in-memory customer 39 | var customer = new Person 40 | { 41 | Name = "John Doe", 42 | Age = 11111, 43 | }; 44 | 45 | var orderId = Guid.NewGuid(); 46 | 47 | var orderTotal = product.Price * request.Quantity; 48 | 49 | var response = new OrderResponse 50 | { 51 | OrderId = orderId, 52 | CustomerName = customer.Name, 53 | ProductName = product.Name, 54 | Quantity = request.Quantity, 55 | TotalAmount = orderTotal, 56 | OrderDate = DateTime.UtcNow, 57 | Message = "Order created successfully." 58 | }; 59 | var evt = new OrderCreatedIntegrationEvent(orderId, orderTotal); 60 | 61 | using var scope = _serviceProvider.CreateScope(); 62 | var integrationService = scope.ServiceProvider.GetRequiredService(); 63 | 64 | // currently it will be added to catalog , i need to setup table order 65 | _catalogDbContext.Product.Add(product); 66 | await integrationService.PublishThroughEventBusAsync(evt); 67 | 68 | return Result.Success(response); 69 | 70 | } 71 | 72 | public class OrderResponse 73 | { 74 | public Guid OrderId { get; set; } 75 | public string CustomerName { get; set; } 76 | public string ProductName { get; set; } 77 | public int Quantity { get; set; } 78 | public decimal TotalAmount { get; set; } 79 | public DateTime OrderDate { get; set; } 80 | public string Message { get; set; } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoid.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using FeatureManagementFilters.Models.Validator; 4 | using FluentValidation; 5 | using Microsoft.AspNetCore.Mvc; 6 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 7 | 8 | 9 | namespace FeatureFusion.Features.Orders.Commands 10 | { 11 | public class CreateOrderCommandVoid : IRequest 12 | { 13 | public int ProductId { get; set; } 14 | public int Quantity { get; set; } 15 | public int CustomerId { get; set; } 16 | } 17 | public class CreateOrderCommandVoidValidator : BaseValidator 18 | { 19 | private readonly ILogger _logger; 20 | 21 | public CreateOrderCommandVoidValidator(ILogger logger) 22 | { 23 | _logger = logger; 24 | RuleFor(x => x.Quantity) 25 | .GreaterThan(0).WithMessage("Quantity must be greater than 0"); 26 | } 27 | public async Task ValidateWithResultAsync(CreateOrderCommand item) 28 | { 29 | var validationResult = await ValidateAsync(item); 30 | 31 | if (!validationResult.IsValid) 32 | { 33 | var validationErrors = validationResult.Errors 34 | .GroupBy(e => e.PropertyName) 35 | .ToDictionary( 36 | group => group.Key, 37 | group => group.Select(e => e.ErrorMessage).ToArray() 38 | ); 39 | 40 | _logger.LogError($"validation error on {nameof(GreetingDto)}: {validationErrors}"); 41 | 42 | var problemDetails = new ValidationProblemDetails 43 | { 44 | Status = StatusCodes.Status400BadRequest, 45 | Title = "One or more validation errors occurred.", 46 | Errors = validationErrors 47 | }; 48 | 49 | return ValidationResult.Failure(problemDetails); 50 | } 51 | 52 | return ValidationResult.Success(); 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Commands/CreateOrderCommandVoidHandler.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.CQRS; 2 | using FeatureManagementFilters.Models; 3 | using static FeatureFusion.Controllers.V2.OrderController; 4 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 5 | using StackExchange.Redis; 6 | using FeatureFusion.Domain.Entities; 7 | namespace FeatureFusion.Features.Orders.Commands 8 | { 9 | public class CreateOrderCommandVoidHandler : IRequestHandler 10 | { 11 | public Task Handle(CreateOrderCommandVoid request, CancellationToken cancellationToken) 12 | { 13 | // Static in-memory product 14 | var product = new Product 15 | { 16 | Id = 12345, 17 | Name = "Smartphone", 18 | Published = true, 19 | Deleted = false, 20 | VisibleIndividually = true, 21 | Price = 599.99m 22 | }; 23 | 24 | // Static in-memory customer 25 | var customer = new Person 26 | { 27 | Name = "John Doe", 28 | Age = 11111, 29 | }; 30 | 31 | var orderId = Ulid.NewUlid(); 32 | 33 | var orderTotal = product.Price * request.Quantity; 34 | 35 | var response = new OrderResponse 36 | { 37 | OrderId = orderId, 38 | CustomerName = customer.Name, 39 | ProductName = product.Name, 40 | Quantity = request.Quantity, 41 | TotalAmount = orderTotal, 42 | OrderDate = DateTime.UtcNow, 43 | Message = "Order created successfully." 44 | }; 45 | return Task.CompletedTask; 46 | 47 | } 48 | 49 | public class OrderResponse 50 | { 51 | public Ulid OrderId { get; set; } 52 | public string CustomerName { get; set; } 53 | public string ProductName { get; set; } 54 | public int Quantity { get; set; } 55 | public decimal TotalAmount { get; set; } 56 | public DateTime OrderDate { get; set; } 57 | public string Message { get; set; } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/IntegrationEvents/EventHandling/OrderCreatedEventHandler.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using FeatureFusion.Features.Order.IntegrationEvents.Events; 3 | 4 | namespace FeatureFusion.Features.Order.IntegrationEvents.EventHandling 5 | { 6 | public class OrderCreatedIntegrationEventHandler 7 | : IIntegrationEventHandler 8 | { 9 | private readonly ILogger _logger; 10 | public List ReceivedEvents { get; } = new(); 11 | 12 | public OrderCreatedIntegrationEventHandler(ILogger logger) 13 | { 14 | _logger = logger; 15 | } 16 | 17 | public Task Handle(OrderCreatedIntegrationEvent @event) 18 | { 19 | _logger.LogInformation("Handling OrderCreatedIntegrationEvent for OrderId: {OrderId}", @event.OrderId); 20 | 21 | try 22 | { 23 | ReceivedEvents.Add(@event); 24 | _logger.LogDebug("Successfully processed order creation event. Total events received: {EventCount}", 25 | ReceivedEvents.Count); 26 | 27 | return Task.CompletedTask; 28 | } 29 | catch (Exception ex) 30 | { 31 | _logger.LogError(ex, "Failed to handle OrderCreatedIntegrationEvent for OrderId: {OrderId}", @event.OrderId); 32 | throw; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/IntegrationEvents/Events/IAllowDirectFallback.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Features.Order.IntegrationEvents.Events 2 | { 3 | public interface IAllowDirectFallback 4 | { 5 | } 6 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/IntegrationEvents/Events/OrderCreatedEvent.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | 3 | namespace FeatureFusion.Features.Order.IntegrationEvents.Events 4 | { 5 | public record OrderCreatedIntegrationEvent : IntegrationEvent // if we need fallback to direct publish : IAllowDirectFallback 6 | { 7 | public Guid OrderId { get; } 8 | public decimal Total { get; } 9 | 10 | public OrderCreatedIntegrationEvent(Guid orderId, decimal total) 11 | { 12 | OrderId = orderId; 13 | Total = total; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/IntegrationEvents/IIntegrationEventService.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | 3 | namespace FeatureFusion.Features.Order.IntegrationEvents; 4 | 5 | public interface IIntegrationEventService 6 | { 7 | Task PublishThroughEventBusAsync(IntegrationEvent evt); 8 | } 9 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/IntegrationEvents/IntegrationEventService.cs: -------------------------------------------------------------------------------- 1 | using EventBusRabbitMQ.Events; 2 | using EventBusRabbitMQ.Infrastructure.EventBus; 3 | using FeatureFusion.Infrastructure.Context; 4 | 5 | namespace FeatureFusion.Features.Order.IntegrationEvents 6 | { 7 | public sealed class IntegrationEventService(ILogger logger, 8 | IEventBus eventBus, 9 | CatalogDbContext catalogContext) 10 | : IIntegrationEventService 11 | { 12 | public async Task PublishThroughEventBusAsync(IntegrationEvent evt) 13 | { 14 | try 15 | { 16 | logger.LogInformation("Publishing integration event: {IntegrationEventId_published} - ({@IntegrationEvent})", evt.Id, evt); 17 | 18 | await ResilientTransaction.New(catalogContext).ExecuteAsync(async () => 19 | { 20 | await catalogContext.SaveChangesAsync(); 21 | await eventBus.PublishAsync(evt, catalogContext.Database.CurrentTransaction); 22 | }); 23 | 24 | } 25 | catch (Exception ex) 26 | { 27 | logger.LogError(ex, "Error Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", evt.Id, evt); 28 | 29 | } 30 | } 31 | 32 | } 33 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Queries/GetOrderQuery.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.CQRS; 2 | using static FeatureFusion.Controllers.V2.OrderController; 3 | using static FeatureFusion.Features.Orders.Commands.CreateOrderCommandHandler; 4 | 5 | namespace FeatureFusion.Features.Order.Queries 6 | { 7 | public record GetOrderQuery(Ulid OrderId) : IRequest; 8 | } 9 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Orders/Types/Results.cs: -------------------------------------------------------------------------------- 1 | public readonly struct Result 2 | { 3 | private readonly T _value; 4 | private readonly string _error= string.Empty; 5 | private readonly int _statusCode; 6 | 7 | public T Value => _value ?? throw new InvalidOperationException("No value for failed result"); 8 | public string Error => _error ?? throw new InvalidOperationException("No error for successful result"); 9 | public int StatusCode => _statusCode; 10 | public bool IsSuccess => _error is null; 11 | 12 | private Result(T value) 13 | { 14 | _value = value; 15 | _error = null; 16 | _statusCode = 0; 17 | } 18 | 19 | private Result(string error, int statusCode) 20 | { 21 | _error = error; 22 | _statusCode = statusCode; 23 | _value = default; 24 | } 25 | 26 | public static Result Success(T value) => new(value); 27 | public static Result Failure(string error, int statusCode) => new(error, statusCode); 28 | 29 | public TResult Match( 30 | Func onSuccess, 31 | Func onFailure) => 32 | IsSuccess ? onSuccess(_value!) : onFailure(_error!, _statusCode); 33 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Products/Queries/GetProductsQuery.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Infrastructure.CQRS; 3 | using FeatureFusion.Infrastructure.CursorPagination; 4 | using Swashbuckle.AspNetCore.Annotations; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.Text.Json.Serialization; 7 | 8 | namespace FeatureFusion.Features.Products.Queries 9 | { 10 | public sealed record GetProductsQuery : IRequest>> 11 | { 12 | [SwaggerParameter(Description = "Maximum number of items to return")] 13 | [Range(1, 100)] 14 | public int Limit { get; init; } = 20; 15 | 16 | [SwaggerParameter(Required = false, Description = "Pagination cursor")] 17 | public string Cursor { get; init; } = string.Empty; 18 | 19 | [SwaggerParameter(Description = "Field to sort by")] 20 | public ProductSortField SortBy { get; init; } = ProductSortField.Id; 21 | 22 | [SwaggerParameter(Description = "Sort direction")] 23 | public SortDirection SortDirection { get; init; } = SortDirection.Ascending; 24 | } 25 | 26 | [JsonConverter(typeof(JsonStringEnumConverter))] 27 | public enum ProductSortField 28 | { 29 | Id, 30 | Name, 31 | Price, 32 | CreatedAt 33 | } 34 | 35 | [JsonConverter(typeof(JsonStringEnumConverter))] 36 | public enum SortDirection 37 | { 38 | Ascending, 39 | Descending 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/FeatureFusion/Features/Products/Queries/GetProductsQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureFusion.Features.Order.IntegrationEvents; 3 | using FeatureFusion.Infrastructure.CQRS; 4 | using FeatureFusion.Infrastructure.CursorPagination; 5 | using FeatureManagementFilters.Services.ProductService; 6 | using static FeatureFusion.Infrastructure.CursorPagination.CursorFactory; 7 | 8 | namespace FeatureFusion.Features.Products.Queries 9 | { 10 | public sealed class GetProductsQueryHandler 11 | : IRequestHandler>> 12 | { 13 | private readonly IServiceProvider _serviceProvider; 14 | 15 | public GetProductsQueryHandler(IServiceProvider serviceProvider) 16 | { 17 | _serviceProvider = serviceProvider; 18 | } 19 | 20 | public async Task>> Handle( 21 | GetProductsQuery request, 22 | CancellationToken cancellationToken) 23 | { 24 | 25 | var productService = _serviceProvider.GetRequiredService(); 26 | try 27 | { 28 | if (request.Limit <= 0 || request.Limit > 100) 29 | { 30 | return Result>.Failure( 31 | "Limit must be between 1 and 100", 32 | StatusCodes.Status400BadRequest); 33 | } 34 | 35 | var result = await productService.GetProductsAsync( 36 | request.Limit, 37 | request.SortBy, 38 | request.SortDirection, 39 | request.Cursor, 40 | cancellationToken); 41 | 42 | return result; 43 | } 44 | catch (OperationCanceledException ex) 45 | { 46 | return Result>.Failure( 47 | $"Request was cancelled : {ex.Message}", 48 | StatusCodes.Status499ClientClosedRequest); 49 | } 50 | catch (Exception ex) 51 | { 52 | return Result>.Failure( 53 | $"An error occurred while retrieving products : {ex.Message}", 54 | StatusCodes.Status500InternalServerError); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/Adapter/Adapter.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Infrastructure.CQRS.Adapter 2 | { 3 | public class VoidCommandAdapter : IRequestHandler, Unit> 4 | where TRequest : IRequest 5 | { 6 | private readonly IRequestHandler _innerHandler; 7 | 8 | public VoidCommandAdapter(IRequestHandler innerHandler) 9 | { 10 | _innerHandler = innerHandler; 11 | } 12 | 13 | public async Task Handle(RequestAdapter request, CancellationToken cancellationToken) 14 | { 15 | await _innerHandler.Handle(request.Request, cancellationToken); 16 | return Unit.Value; 17 | } 18 | } 19 | 20 | public class RequestAdapter : IRequest 21 | where TRequest : IRequest 22 | { 23 | public TRequest Request { get; } 24 | public RequestAdapter(TRequest request) => Request = request; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/IMediator.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Models; 2 | using static FeatureFusion.Infrastructure.CQRS.Mediator; 3 | 4 | namespace FeatureFusion.Infrastructure.CQRS 5 | { 6 | public interface IMediator 7 | { 8 | Task Send(IRequest request, CancellationToken cancellationToken = default); 9 | 10 | Task Send(TRequest request, CancellationToken cancellationToken = default) 11 | where TRequest : IRequest; 12 | } 13 | 14 | public interface IRequest { } 15 | 16 | public interface IRequest { } 17 | 18 | public interface IRequestHandler 19 | where TRequest : IRequest 20 | { 21 | Task Handle(TRequest request, CancellationToken cancellationToken); 22 | } 23 | 24 | public interface IRequestHandler 25 | where TRequest : IRequest 26 | { 27 | Task Handle(TRequest request, CancellationToken cancellationToken); 28 | } 29 | 30 | public interface IPipelineBehavior 31 | { 32 | Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken = default); 33 | } 34 | 35 | public interface IPipelineBehavior 36 | { 37 | Task Handle(TRequest request, VoidRequestHandlerDelegate next, CancellationToken cancellationToken = default); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/Mediator.cs: -------------------------------------------------------------------------------- 1 |  2 | 3 | using FeatureFusion.Infrastructure.CQRS.Wrapper; 4 | using System.Collections.Concurrent; 5 | using FeatureFusion.Infrastructure.CQRS.Adapter; 6 | 7 | namespace FeatureFusion.Infrastructure.CQRS 8 | { 9 | public class Mediator : IMediator 10 | { 11 | 12 | public delegate Task RequestHandlerDelegate(CancellationToken cancellationToken); 13 | public delegate Task VoidRequestHandlerDelegate(CancellationToken cancellationToken); 14 | private readonly IServiceProvider _serviceProvider; 15 | private static readonly ConcurrentDictionary _requestHandlers = new(); 16 | private static readonly ConcurrentDictionary _behaviorPipelines = new(); 17 | private static readonly ConcurrentDictionary>> _adapterCache = new(); 18 | 19 | public Mediator(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider; 20 | 21 | public Task Send(IRequest request, CancellationToken cancellationToken = default) 22 | { 23 | if (request == null) throw new ArgumentNullException(nameof(request)); 24 | 25 | var requestType = request.GetType(); 26 | var handler = GetRequestHandler(requestType); 27 | var pipeline = GetBehaviorPipeline(requestType); 28 | 29 | return handler.Handle(request, pipeline, _serviceProvider, cancellationToken); 30 | } 31 | 32 | 33 | public Task Send(TRequest request, CancellationToken ct = default) where TRequest : IRequest 34 | { 35 | if (request == null) throw new ArgumentNullException(nameof(request)); 36 | var adapterFactory = _adapterCache.GetOrAdd(request.GetType(), type => 37 | { 38 | var adapterType = typeof(RequestAdapter<>).MakeGenericType(type); 39 | return cmd => (IRequest)Activator.CreateInstance(adapterType, cmd)!; 40 | }); 41 | 42 | return Send(adapterFactory(request), ct); 43 | 44 | } 45 | 46 | private RequestHandlerWrapper GetRequestHandler(Type requestType) 47 | { 48 | if (_requestHandlers.TryGetValue(requestType, out var cachedHandler)) 49 | return (RequestHandlerWrapper)cachedHandler; 50 | 51 | var wrapperType = typeof(RequestHandlerWrapper<,>).MakeGenericType(requestType, typeof(TResponse)); 52 | var newWrapper = (RequestHandlerWrapper)Activator.CreateInstance(wrapperType)!; 53 | 54 | return (RequestHandlerWrapper)_requestHandlers.GetOrAdd(requestType, newWrapper); 55 | } 56 | 57 | private PipelineBehaviorWrapper GetBehaviorPipeline(Type requestType) 58 | { 59 | if (_behaviorPipelines.TryGetValue(requestType, out var cachedPipeline)) 60 | return (PipelineBehaviorWrapper)cachedPipeline; 61 | 62 | var wrapperType = typeof(PipelineBehaviorWrapper<,>).MakeGenericType(requestType, typeof(TResponse)); 63 | var newWrapper = (PipelineBehaviorWrapper)Activator.CreateInstance(wrapperType)!; 64 | 65 | return (PipelineBehaviorWrapper)_behaviorPipelines.GetOrAdd(requestType, newWrapper); 66 | } 67 | 68 | 69 | } 70 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/Unit.cs: -------------------------------------------------------------------------------- 1 |  2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | public readonly struct Unit : IEquatable, IComparable, IComparable 6 | { 7 | private static readonly Unit _value = new(); 8 | 9 | public static ref readonly Unit Value => ref _value; 10 | 11 | public static Task Task { get; } = System.Threading.Tasks.Task.FromResult(_value); 12 | 13 | public int CompareTo(Unit other) => 0; 14 | 15 | int IComparable.CompareTo(object obj) => 0; 16 | 17 | 18 | public override int GetHashCode() => 0; 19 | 20 | public bool Equals(Unit other) => true; 21 | 22 | 23 | public override bool Equals(object obj) => obj is Unit; 24 | 25 | 26 | public static bool operator ==(Unit first, Unit second) => true; 27 | 28 | 29 | public static bool operator !=(Unit first, Unit second) => false; 30 | public override string ToString() => "()"; 31 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/Wrapper/PipelineBehaviorWrappers.cs: -------------------------------------------------------------------------------- 1 | using static FeatureFusion.Infrastructure.CQRS.Mediator; 2 | 3 | namespace FeatureFusion.Infrastructure.CQRS.Wrapper 4 | { 5 | internal abstract class PipelineBehaviorWrapper { } 6 | internal abstract class PipelineBehaviorWrapper : PipelineBehaviorWrapper 7 | { 8 | public abstract Task Handle( 9 | IRequest request, 10 | RequestHandlerDelegate next, 11 | IServiceProvider serviceProvider, 12 | CancellationToken cancellationToken); 13 | } 14 | 15 | internal class PipelineBehaviorWrapper : PipelineBehaviorWrapper 16 | where TRequest : IRequest 17 | { 18 | public override async Task Handle( 19 | IRequest request, 20 | RequestHandlerDelegate next, 21 | IServiceProvider serviceProvider, 22 | CancellationToken cancellationToken) 23 | { 24 | var behaviors = serviceProvider.GetServices>(); 25 | var pipeline = next; 26 | foreach (var behavior in behaviors.Reverse()) 27 | { 28 | var current = pipeline; 29 | pipeline = ct => behavior.Handle((TRequest)request, current, ct); 30 | } 31 | return await pipeline(cancellationToken); 32 | } 33 | } 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CQRS/Wrapper/RequestHandlerWrappers.cs: -------------------------------------------------------------------------------- 1 |  2 | using FeatureFusion.Infrastructure.CQRS.Adapter; 3 | using static FeatureFusion.Infrastructure.CQRS.Mediator; 4 | namespace FeatureFusion.Infrastructure.CQRS.Wrapper 5 | { 6 | 7 | internal abstract class RequestHandlerWrapper { } 8 | internal abstract class RequestHandlerWrapper : RequestHandlerWrapper 9 | { 10 | public abstract Task Handle( 11 | IRequest request, 12 | PipelineBehaviorWrapper pipeline, 13 | IServiceProvider serviceProvider, 14 | CancellationToken cancellationToken); 15 | } 16 | 17 | internal class RequestHandlerWrapper : RequestHandlerWrapper 18 | where TRequest : IRequest 19 | { 20 | public override Task Handle( 21 | IRequest request, 22 | PipelineBehaviorWrapper pipeline, 23 | IServiceProvider serviceProvider, 24 | CancellationToken cancellationToken) 25 | { 26 | 27 | var normalhandler = serviceProvider.GetRequiredService>(); 28 | return pipeline.Handle( 29 | request, 30 | ct => normalhandler.Handle((TRequest)request, ct), 31 | serviceProvider, 32 | cancellationToken); 33 | } 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/CacheKey.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureManagementFilters.Infrastructure.Caching 2 | { 3 | /// 4 | /// Represents key for caching objects 5 | /// 6 | public partial class CacheKey 7 | { 8 | #region Ctor 9 | 10 | /// 11 | /// Initialize a new instance with key and prefixes 12 | /// 13 | /// Key 14 | /// Prefixes for remove by prefix functionality 15 | public CacheKey(string key, params string[] prefixes) 16 | { 17 | Key = key; 18 | Prefixes.AddRange(prefixes.Where(prefix => !string.IsNullOrEmpty(prefix))); 19 | } 20 | 21 | #endregion 22 | 23 | #region Methods 24 | 25 | /// 26 | /// Create a new instance from the current one and fill it with passed parameters 27 | /// 28 | /// Function to create parameters 29 | /// Objects to create parameters 30 | /// Cache key 31 | public virtual CacheKey Create(Func createCacheKeyParameters, params object[] keyObjects) 32 | { 33 | var cacheKey = new CacheKey(Key, Prefixes.ToArray()); 34 | 35 | if (!keyObjects.Any()) 36 | return cacheKey; 37 | 38 | cacheKey.Key = string.Format(cacheKey.Key, keyObjects.Select(createCacheKeyParameters).ToArray()); 39 | 40 | for (var i = 0; i < cacheKey.Prefixes.Count; i++) 41 | cacheKey.Prefixes[i] = string.Format(cacheKey.Prefixes[i], keyObjects.Select(createCacheKeyParameters).ToArray()); 42 | 43 | return cacheKey; 44 | } 45 | 46 | #endregion 47 | 48 | #region Properties 49 | 50 | /// 51 | /// Gets or sets a cache key 52 | /// 53 | public string Key { get; protected set; } 54 | 55 | /// 56 | /// Gets or sets prefixes for remove by prefix functionality 57 | /// 58 | public List Prefixes { get; protected set; } = new List(); 59 | 60 | /// 61 | /// Gets or sets a cache time in minutes 62 | /// 63 | public int CacheTime { get; set; } = 1; 64 | /// 65 | /// Gets or sets a cache time in seconds 66 | /// 67 | public int CacheTimeSecond { get; set; } = 60; 68 | 69 | #endregion 70 | } 71 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/IDistributedCacheManager.cs: -------------------------------------------------------------------------------- 1 | using FeatureManagementFilters.Infrastructure.Caching; 2 | using Microsoft.Extensions.Caching.Hybrid; 3 | 4 | public interface IDistributedCacheManager 5 | { 6 | Task GetValueOrCreateAsync(CacheKey key, Func> acquire,CancellationToken cancellationToken=default); 7 | Task RefreshCacheAsync(string key, Func> fetchFromDb, int cacheMinutes); 8 | Task RemoveAsync(string cacheKey, CancellationToken token); 9 | 10 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/IRedisConnectionWrapper.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using System.Net; 3 | 4 | namespace FeatureFusion.Infrastructure.Caching 5 | { 6 | public partial interface IRedisConnectionWrapper 7 | { 8 | /// 9 | /// Obtain an interactive connection to a database inside Redis 10 | /// 11 | /// Redis cache database 12 | Task GetDatabaseAsync(); 13 | 14 | /// 15 | /// Obtain an interactive connection to a database inside Redis 16 | /// 17 | /// Redis cache database 18 | IDatabase GetDatabase(); 19 | 20 | /// 21 | /// Obtain a configuration API for an individual server 22 | /// 23 | /// The network endpoint 24 | /// Redis server 25 | Task GetServerAsync(EndPoint endPoint); 26 | 27 | /// 28 | /// Gets all endpoints defined on the server 29 | /// 30 | /// Array of endpoints 31 | Task GetEndPointsAsync(); 32 | 33 | /// 34 | /// Gets a subscriber for the server 35 | /// 36 | /// Array of endpoints 37 | Task GetSubscriberAsync(); 38 | 39 | /// 40 | /// Gets a subscriber for the server 41 | /// 42 | /// Array of endpoints 43 | ISubscriber GetSubscriber(); 44 | 45 | /// 46 | /// Delete all the keys of the database 47 | /// 48 | Task FlushDatabaseAsync(); 49 | 50 | 51 | /// 52 | /// Acquire a distributed lock. 53 | /// 54 | /// True if the lock was acquired, false otherwise. 55 | Task AcquireLockAsync(string key, string value, TimeSpan expiry); 56 | 57 | 58 | /// 59 | /// Releases a distributed lock. 60 | /// 61 | /// True if the lock was released, false otherwise. 62 | Task ReleaseLockAsync(string key, string value); 63 | 64 | 65 | /// 66 | /// The Redis instance name 67 | /// 68 | string Instance { get; } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/IStaticCacheManager.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureManagementFilters.Infrastructure.Caching 2 | { 3 | 4 | 5 | 6 | /// 7 | /// Represents a manager for caching between HTTP requests (long term caching) 8 | /// 9 | public interface IStaticCacheManager : IDisposable 10 | { 11 | /// 12 | /// Get a cached item. If it's not in the cache yet, then load and cache it 13 | /// 14 | /// Type of cached item 15 | /// Cache key 16 | /// Function to load item if it's not in the cache yet 17 | /// 18 | /// A task that represents the asynchronous operation 19 | /// The task result contains the cached value associated with the specified key 20 | /// 21 | Task GetAsync(CacheKey key, Func> acquire); 22 | 23 | /// 24 | /// Get a cached item. 25 | /// 26 | /// Type of cached item 27 | /// Cache key 28 | /// 29 | /// A task that represents the asynchronous operation 30 | /// The task result contains the cached value associated with the specified key 31 | /// 32 | Task TryGetAsync(CacheKey key); 33 | /// 34 | /// Get a cached item. If it's not in the cache yet, then load and cache it 35 | /// 36 | /// Type of cached item 37 | /// Cache key 38 | /// Function to load item if it's not in the cache yet 39 | /// 40 | /// A task that represents the asynchronous operation 41 | /// The task result contains the cached value associated with the specified key 42 | /// 43 | Task GetAsync(CacheKey key, Func acquire); 44 | 45 | /// 46 | /// Get a cached item. If it's not in the cache yet, then load and cache it 47 | /// 48 | /// Type of cached item 49 | /// Cache key 50 | /// Function to load item if it's not in the cache yet 51 | /// The cached value associated with the specified key 52 | T Get(CacheKey key, Func acquire); 53 | 54 | /// 55 | /// Remove the value with the specified key from the cache 56 | /// 57 | /// Cache key 58 | /// Parameters to create cache key 59 | /// A task that represents the asynchronous operation 60 | Task RemoveAsync(CacheKey cacheKey, params object[] cacheKeyParameters); 61 | 62 | /// 63 | /// Add the specified key and object to the cache 64 | /// 65 | /// Key of cached item 66 | /// Value for caching 67 | /// A task that represents the asynchronous operation 68 | Task SetAsync(CacheKey key, object data); 69 | 70 | /// 71 | /// Remove items by cache key prefix 72 | /// 73 | /// Cache key prefix 74 | /// Parameters to create cache key prefix 75 | /// A task that represents the asynchronous operation 76 | Task RemoveByPrefixAsync(string prefix, params object[] prefixParameters); 77 | 78 | /// 79 | /// Clear all cache data 80 | /// 81 | /// A task that represents the asynchronous operation 82 | Task ClearAsync(); 83 | 84 | #region Cache key 85 | 86 | /// 87 | /// Create a copy of cache key and fills it by passed parameters 88 | /// 89 | /// Initial cache key 90 | /// Parameters to create cache key 91 | /// Cache key 92 | CacheKey PrepareKey(CacheKey cacheKey, params object[] cacheKeyParameters); 93 | 94 | /// 95 | /// Create a copy of cache key using the default cache time and fills it by passed parameters 96 | /// 97 | /// Initial cache key 98 | /// Parameters to create cache key 99 | /// Cache key 100 | CacheKey PrepareKeyForDefaultCache(CacheKey cacheKey, params object[] cacheKeyParameters); 101 | 102 | /// 103 | /// Create a copy of cache key using the short cache time and fills it by passed parameters 104 | /// 105 | /// Initial cache key 106 | /// Parameters to create cache key 107 | /// Cache key 108 | CacheKey PrepareKeyForShortTermCache(CacheKey cacheKey, params object[] cacheKeyParameters); 109 | 110 | #endregion 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/MemcachedCacheManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Enyim.Caching; 6 | using FeatureManagementFilters.Infrastructure.Caching; 7 | using Microsoft.AspNetCore.DataProtection.KeyManagement; 8 | using Microsoft.Extensions.Caching.Hybrid; 9 | using Microsoft.Extensions.Logging; 10 | 11 | 12 | public class MemcachedCacheManager : IDistributedCacheManager 13 | { 14 | 15 | private readonly IMemcachedClient _memcachedClient; 16 | private readonly ILogger _logger; 17 | private static readonly ConcurrentDictionary _locks = new(); 18 | public MemcachedCacheManager(IMemcachedClient memcachedClient, ILogger logger) 19 | { 20 | _memcachedClient = memcachedClient; 21 | _logger = logger; 22 | } 23 | public async Task GetValueOrCreateAsync(CacheKey key, Func> acquire,CancellationToken cancellationToken=default) 24 | { 25 | try 26 | { 27 | // Use GetValueOrCreateAsync to handle cache misses and data fetching in one call 28 | var cacheEntry = await _memcachedClient.GetValueOrCreateAsync( 29 | key.Key, // Cache key 30 | key.CacheTimeSecond, // Cache expiration time 31 | async () => await acquire() // Factory method to fetch data if cache miss 32 | 33 | ); 34 | _logger.LogInformation("cacheEntry for key {key.key} is {cacheEntry}:",key.Key, cacheEntry); 35 | return cacheEntry; 36 | } 37 | catch (Exception ex) 38 | { 39 | // Log errors and fallback to fetching fresh data 40 | _logger.LogError("Memcached Error {ex.Message}", ex.Message); 41 | return await acquire(); // Fallback to Func (can be a db fetch) 42 | } 43 | } 44 | public async Task RefreshCacheAsync(string key, Func> fetchFromDb, int cacheMinutes) 45 | { 46 | try 47 | { 48 | var freshData = await fetchFromDb(); 49 | await _memcachedClient.SetAsync(key, freshData, TimeSpan.FromMinutes(cacheMinutes)); 50 | } 51 | catch (Exception ex) 52 | { 53 | Console.WriteLine($"[Memcached Refresh Error] {ex.Message}"); 54 | throw; 55 | } 56 | } 57 | 58 | 59 | 60 | public async Task RemoveAsync(string cacheKey, CancellationToken token) 61 | { 62 | try 63 | { 64 | await _memcachedClient.RemoveAsync(cacheKey); 65 | } 66 | catch (Exception ex) 67 | { 68 | Console.WriteLine($"[Memcached remove Error] {ex.Message}"); 69 | throw; 70 | } 71 | } 72 | 73 | 74 | 75 | public async Task SetAsync(string cacheKey, object value , CancellationToken cancellationToken) 76 | { 77 | try 78 | { 79 | await _memcachedClient.SetAsync(cacheKey,value, TimeSpan.FromMinutes(1)); 80 | } 81 | catch (Exception ex) 82 | { 83 | Console.WriteLine($"[Memcached remove Error] {ex.Message}"); 84 | throw; 85 | } 86 | } 87 | } 88 | 89 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/RedisCacheManager.cs: -------------------------------------------------------------------------------- 1 | using Enyim.Caching; 2 | using FeatureManagementFilters.Infrastructure.Caching; 3 | using StackExchange.Redis; 4 | using System.Collections.Concurrent; 5 | using System.Text.Json; 6 | 7 | namespace FeatureFusion.Infrastructure.Caching 8 | { 9 | internal class RedisCacheManager : IDistributedCacheManager 10 | { 11 | 12 | protected readonly IRedisConnectionWrapper _connectionWrapper; 13 | protected readonly ILogger _logger; 14 | private static readonly ConcurrentDictionary _locks = new(); 15 | public RedisCacheManager(IRedisConnectionWrapper connectionWrapper, 16 | ILogger logger) 17 | { 18 | _connectionWrapper = connectionWrapper; 19 | _logger = logger; 20 | } 21 | public async Task GetValueOrCreateAsync(CacheKey key, Func> acquire) 22 | { 23 | 24 | var database = await _connectionWrapper.GetDatabaseAsync(); 25 | string redisKey = key.ToString(); 26 | 27 | // Lua script: Atomically get existing value or set if missing 28 | var luaScript = @" 29 | local value = redis.call('GET', KEYS[1]) 30 | if value then 31 | return value 32 | else 33 | redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) 34 | return ARGV[1] 35 | end"; 36 | 37 | var cachedData = (string)await database.ScriptEvaluateAsync(luaScript, 38 | new RedisKey[] { redisKey }, 39 | new RedisValue[] { JsonSerializer.Serialize(await acquire()), key.CacheTime * 60 }); 40 | 41 | return cachedData is not null ? JsonSerializer.Deserialize(cachedData) : default!; 42 | } 43 | 44 | public async Task RefreshCacheAsync(string key, Func> fetchFromDb, int cacheMinutes) 45 | { 46 | var database = await _connectionWrapper.GetDatabaseAsync(); 47 | 48 | // Fetch fresh data from source 49 | var newData = await fetchFromDb(); 50 | if (newData != null) 51 | { 52 | var serializedData = JsonSerializer.Serialize(newData); 53 | await database.StringSetAsync(key, serializedData, TimeSpan.FromMinutes(cacheMinutes)); 54 | } 55 | } 56 | 57 | public Task GetValueOrCreateAsync(CacheKey key, Func> acquire, CancellationToken cancellationToken = default) 58 | { 59 | // I currently using DistributedCache from microsoft for redis not manual one 60 | // TODO: implement 61 | 62 | throw new NotImplementedException(); 63 | } 64 | 65 | public Task RemoveAsync(string cacheKey, CancellationToken token) 66 | { 67 | // I currently using DistributedCache from microsoft for redis not manual one 68 | // TODO: implement 69 | throw new NotImplementedException(); 70 | } 71 | } 72 | } 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Caching/RedisOptions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | public class RedisSettings 4 | { 5 | public class RedisOptions 6 | { 7 | [Required(ErrorMessage = "Redis connection string is required.")] 8 | public string ConnectionString { get; set; } 9 | 10 | public string InstanceName { get; set; } = "MyApp:"; 11 | } 12 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CursorPagination/CursorFactory.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Domain.Entities; 2 | using FeatureFusion.Features.Products.Queries; 3 | using Npgsql.Internal; 4 | using System.Text; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | using static FeatureFusion.Infrastructure.CursorPagination.CursorFactory; 8 | using static PaginationHelper; 9 | 10 | namespace FeatureFusion.Infrastructure.CursorPagination 11 | { 12 | public static class CursorFactory 13 | { 14 | private static readonly JsonSerializerOptions _options = new() 15 | { 16 | PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, 17 | Converters = { new JsonStringEnumConverter() } 18 | }; 19 | 20 | public static string Create(TEntity entity, string sortBy, SortDirection direction,int pageInndex) 21 | { 22 | var value = typeof(TEntity).GetProperty(sortBy)?.GetValue(entity); 23 | var id = (int)typeof(TEntity).GetProperty("Id")?.GetValue(entity); 24 | 25 | var cursor = new CursorData( 26 | LastValue: value, 27 | LastId: id, 28 | SortBy: sortBy, 29 | Direction: direction, 30 | pageInndex 31 | ); 32 | 33 | return Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cursor, _options))); 34 | } 35 | 36 | public static CursorData Decode(string encodedCursor) 37 | { 38 | if (string.IsNullOrEmpty(encodedCursor)) return null; 39 | 40 | try 41 | { 42 | var json = JsonSerializer.Deserialize( 43 | Encoding.UTF8.GetString(Convert.FromBase64String(encodedCursor)), 44 | _options); 45 | return json; 46 | } 47 | catch 48 | { 49 | return null; 50 | } 51 | } 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/CursorPagination/PagedResult.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Infrastructure.CursorPagination 2 | { 3 | public record PagedResult( 4 | IReadOnlyList Items, 5 | string NextCursor, 6 | string PreviousCursor, 7 | bool HasMore, 8 | bool HasPrevious, 9 | int TotalCount) 10 | { 11 | public static PagedResult Empty { get; } = new( 12 | [], 13 | string.Empty, 14 | string.Empty, 15 | false, 16 | false, 17 | 0); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/DbContext/CatalogDContextSeed.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Domain.Entities; 2 | using FeatureFusion.Infrastructure.Exetnsion; 3 | using FeatureManagementFilters.Models; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Options; 6 | using Npgsql; 7 | using System.Text.Json; 8 | 9 | namespace FeatureFusion.Infrastructure.Context; 10 | 11 | public partial class CatalogDContextSeed( 12 | IWebHostEnvironment env, 13 | ILogger logger) : IDbSeeder 14 | { 15 | public async Task SeedAsync(CatalogDbContext context) 16 | { 17 | var contentRootPath = env.ContentRootPath; 18 | var picturePath = env.WebRootPath; 19 | 20 | 21 | context.Database.OpenConnection(); 22 | ((NpgsqlConnection)context.Database.GetDbConnection()).ReloadTypes(); 23 | 24 | 25 | if (!context.Product.Any()) 26 | { 27 | 28 | var sourcePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Setup", "catalog.json"); 29 | var sourceJson = File.ReadAllText(sourcePath); 30 | var sourceItems = JsonSerializer.Deserialize(sourceJson); 31 | 32 | await context.SaveChangesAsync(); 33 | 34 | var catalogItems = sourceItems.Select(source => new Product 35 | { 36 | Id = source.Id, 37 | Name = source.Name, 38 | Price = source.Price, 39 | CreatedAt=source.CreatedAt 40 | 41 | }).ToArray(); 42 | 43 | await context.Product.AddRangeAsync(catalogItems); 44 | logger.LogInformation("Seeded catalog with {NumItems} items", context.Product.Count()); 45 | await context.SaveChangesAsync(); 46 | } 47 | } 48 | 49 | private class CatalogSourceEntry 50 | { 51 | public int Id { get; set; } 52 | public string Type { get; set; } 53 | public string Brand { get; set; } 54 | public string Name { get; set; } 55 | public string Description { get; set; } 56 | public decimal Price { get; set; } 57 | public DateTime CreatedAt { get; set; } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/DbContext/CatalogDbContext.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.EntitiyConfiguration; 2 | using Microsoft.EntityFrameworkCore; 3 | using EventBusRabbitMQ.Extensions; 4 | using EventBusRabbitMQ.Domain; 5 | using EventBusRabbitMQ.Infrastructure.Context; 6 | using System.ComponentModel.DataAnnotations.Schema; 7 | using FeatureFusion.Domain.Entities; 8 | 9 | namespace FeatureFusion.Infrastructure.Context; 10 | 11 | 12 | public class CatalogDbContext : DbContext, IEventStoreDbContext 13 | { 14 | public CatalogDbContext(DbContextOptions options, IConfiguration configuration) 15 | : base(options) 16 | { 17 | } 18 | public DbSet OutboxMessages { get; set; } 19 | public DbSet InboxMessages { get; set; } 20 | public DbSet ProcessedMessages { get; set; } 21 | public DbSet InboxSubscriber { get; set; } 22 | public DbSet Product { get; set; } 23 | 24 | protected override void OnModelCreating(ModelBuilder builder) 25 | { 26 | builder.ApplyConfiguration(new ProductEntityTypeConfiguration()); 27 | builder.UseEventStore(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | using static Grpc.Core.Metadata; 5 | 6 | namespace FeatureFusion.Infrastructure.EntitiyConfiguration; 7 | 8 | class ProductEntityTypeConfiguration 9 | : IEntityTypeConfiguration 10 | { 11 | public void Configure(EntityTypeBuilder builder) 12 | { 13 | builder.ToTable("products"); 14 | builder.HasKey(p => p.Id); 15 | 16 | builder.Property(p => p.Id) 17 | .ValueGeneratedOnAdd(); 18 | 19 | builder.Property(ci => ci.Name); 20 | 21 | builder.Property(p => p.CreatedAt) 22 | .HasConversion( 23 | v => v, 24 | v => DateTime.SpecifyKind(v, DateTimeKind.Utc) 25 | ); 26 | 27 | builder.HasIndex(ci => ci.Name) 28 | .HasDatabaseName("IX_products_name"); ; 29 | 30 | 31 | builder.HasIndex(ci => ci.CreatedAt) 32 | .IsDescending(false) 33 | .HasDatabaseName("IX_products_created_at_asc"); 34 | 35 | builder.HasIndex(ci => ci.CreatedAt) 36 | .IsDescending(true) 37 | .HasDatabaseName("IX_products_created_at_desc"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/ActivityExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | internal static class ActivityExtensions 4 | { 5 | // See https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/exceptions/ 6 | public static void SetExceptionTags(this Activity activity, Exception ex) 7 | { 8 | if (activity is null) 9 | { 10 | return; 11 | } 12 | 13 | activity.AddTag("exception.message", ex.Message); 14 | activity.AddTag("exception.stacktrace", ex.ToString()); 15 | activity.AddTag("exception.type", ex.GetType().FullName); 16 | activity.SetStatus(ActivityStatusCode.Error); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/EndpointExtension.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Infrastructure.Exetnsion 2 | { 3 | public static class EndpointRouteBuilderExtensions 4 | { 5 | public static RouteHandlerBuilder MapPostWithValidation( 6 | this IEndpointRouteBuilder endpoints, 7 | string pattern, 8 | Delegate handler) 9 | { 10 | return endpoints.MapPost(pattern, handler) 11 | .AddEndpointFilter>(); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/MigrateDbContextExtensions.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.Exetnsion; 2 | using System.Diagnostics; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Logging; 5 | 6 | 7 | namespace FeatureFusion.Infrastructure.Exetnsion; 8 | 9 | internal static class MigrateDbContextExtensions 10 | { 11 | private static readonly string ActivitySourceName = "DbMigrations"; 12 | private static readonly ActivitySource ActivitySource = new(ActivitySourceName); 13 | 14 | public static IServiceCollection AddMigration(this IServiceCollection services) 15 | where TContext : DbContext 16 | => services.AddMigration((_, _) => Task.CompletedTask); 17 | 18 | public static IServiceCollection AddMigration(this IServiceCollection services, Func seeder) 19 | where TContext : DbContext 20 | { 21 | // Enable migration tracing 22 | services.AddOpenTelemetry().WithTracing(tracing => tracing.AddSource(ActivitySourceName)); 23 | 24 | return services.AddHostedService(sp => new MigrationHostedService(sp, seeder)); 25 | } 26 | 27 | public static IServiceCollection AddMigration(this IServiceCollection services) 28 | where TContext : DbContext 29 | where TDbSeeder : class, IDbSeeder 30 | { 31 | services.AddScoped, TDbSeeder>(); 32 | return services.AddMigration((context, sp) => sp.GetRequiredService>().SeedAsync(context)); 33 | } 34 | 35 | private static async Task MigrateDbContextAsync(this IServiceProvider services, Func seeder) where TContext : DbContext 36 | { 37 | using var scope = services.CreateScope(); 38 | var scopeServices = scope.ServiceProvider; 39 | var logger = scopeServices.GetRequiredService>(); 40 | var context = scopeServices.GetService(); 41 | 42 | using var activity = ActivitySource.StartActivity($"Migration operation {typeof(TContext).Name}"); 43 | 44 | try 45 | { 46 | logger.LogInformation("Migrating database associated with context {DbContextName}", typeof(TContext).Name); 47 | 48 | var strategy = context.Database.CreateExecutionStrategy(); 49 | 50 | await strategy.ExecuteAsync(() => InvokeSeeder(seeder, context, scopeServices)); 51 | } 52 | catch (Exception ex) 53 | { 54 | logger.LogError(ex, "An error occurred while migrating the database used on context {DbContextName}", typeof(TContext).Name); 55 | 56 | activity.SetExceptionTags(ex); 57 | 58 | throw; 59 | } 60 | } 61 | 62 | private static async Task InvokeSeeder(Func seeder, TContext context, IServiceProvider services) 63 | where TContext : DbContext 64 | { 65 | using var activity = ActivitySource.StartActivity($"Migrating {typeof(TContext).Name}"); 66 | 67 | try 68 | { 69 | await context.Database.MigrateAsync(); 70 | await seeder(context, services); 71 | } 72 | catch (Exception ex) 73 | { 74 | activity.SetExceptionTags(ex); 75 | 76 | throw; 77 | } 78 | } 79 | 80 | private class MigrationHostedService(IServiceProvider serviceProvider, Func seeder) 81 | : BackgroundService where TContext : DbContext 82 | { 83 | public override Task StartAsync(CancellationToken cancellationToken) 84 | { 85 | return serviceProvider.MigrateDbContextAsync(seeder); 86 | } 87 | 88 | protected override Task ExecuteAsync(CancellationToken stoppingToken) 89 | { 90 | return Task.CompletedTask; 91 | } 92 | } 93 | } 94 | public interface IDbSeeder where TContext : DbContext 95 | { 96 | Task SeedAsync(TContext context); 97 | } 98 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/ProductExtension.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Domain.Entities; 2 | using FeatureFusion.Dtos; 3 | 4 | namespace FeatureFusion.Infrastructure.Exetnsion 5 | { 6 | public static class ProductExtensions 7 | { 8 | public static ProductDto ToDto(this Product product) => new( 9 | product.Id, 10 | product.Name, 11 | product.Price, 12 | product.FullDescription, 13 | product.CreatedAt); 14 | 15 | public static List ToDtos(this IEnumerable products) => 16 | products.Select(p => p.ToDto()).ToList(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/RouteHandlerBuilderExtension.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.ValidationProvider; 2 | using FluentValidation; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | public static class RouteHandlerBuilderExtensions 7 | { 8 | /// 9 | /// Adds model validation to an endpoint using FluentValidation. 10 | /// If a validator for exists, it validates the request before executing the handler. 11 | /// If validation fails, it returns a response. 12 | /// 13 | public static RouteHandlerBuilder WithValidation(this RouteHandlerBuilder builder) 14 | { 15 | return builder.AddEndpointFilter(async (context, next) => 16 | { 17 | var requestModel = context.Arguments.OfType().FirstOrDefault(); 18 | if (requestModel == null) return await next(context); 19 | 20 | // Resolving from DI is preferred to keep dependencies managed and avoid manual injection. 21 | var validatorProvider = context.HttpContext.RequestServices.GetRequiredService(); 22 | var validator = validatorProvider.GetValidator(); 23 | 24 | if (validator == null) return await next(context); 25 | 26 | var validationResult = await validator.ValidateAsync(new ValidationContext(requestModel)); 27 | 28 | return validationResult.IsValid 29 | ? await next(context) 30 | : Results.ValidationProblem(validationResult.ToDictionary()); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Exetnsion/ValidationProblemHelper.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Infrastructure.Exetnsion 2 | { 3 | using Microsoft.AspNetCore.Mvc; 4 | using System.Collections.Generic; 5 | 6 | public static class ValidationProblemHelper 7 | { 8 | /// 9 | /// Creates a standardized 400 Bad Request response with a custom error message. 10 | /// 11 | /// The title of the error. 12 | /// The detailed error message. 13 | /// A with a validation problem details object. 14 | public static BadRequestObjectResult CreateBadRequest(string title, string detail) 15 | { 16 | var problemDetails = new ValidationProblemDetails 17 | { 18 | Title = title, 19 | Detail = detail, 20 | Status = 400 21 | }; 22 | 23 | return new BadRequestObjectResult(problemDetails); 24 | } 25 | 26 | /// 27 | /// Creates a standardized 401 Unauthorized response with a custom error message. 28 | /// 29 | /// The title of the error. 30 | /// The detailed error message. 31 | /// An with a validation problem details object. 32 | public static UnauthorizedObjectResult CreateUnauthorized(string title, string detail) 33 | { 34 | var problemDetails = new ValidationProblemDetails 35 | { 36 | Title = title, 37 | Detail = detail, 38 | Status = 401 39 | }; 40 | 41 | return new UnauthorizedObjectResult(problemDetails); 42 | } 43 | 44 | /// 45 | /// Creates a standardized 403 Forbidden response with a custom error message. 46 | /// 47 | /// The title of the error. 48 | /// The detailed error message. 49 | /// A with a validation problem details object. 50 | public static ObjectResult CreateForbidden(string title, string detail) 51 | { 52 | var problemDetails = new ValidationProblemDetails 53 | { 54 | Title = title, 55 | Detail = detail, 56 | Status = 403 57 | }; 58 | 59 | return new ObjectResult(problemDetails) { StatusCode = 403 }; 60 | } 61 | 62 | /// 63 | /// Creates a standardized 404 Not Found response with a custom error message. 64 | /// 65 | /// The title of the error. 66 | /// The detailed error message. 67 | /// A with a validation problem details object. 68 | public static NotFoundObjectResult CreateNotFound(string title, string detail) 69 | { 70 | var problemDetails = new ValidationProblemDetails 71 | { 72 | Title = title, 73 | Detail = detail, 74 | Status = 404 75 | }; 76 | 77 | return new NotFoundObjectResult(problemDetails); 78 | } 79 | 80 | /// 81 | /// Creates a standardized 408 Request Timeout response with a custom error message. 82 | /// 83 | /// The title of the error. 84 | /// The detailed error message. 85 | /// An with a validation problem details object. 86 | public static ObjectResult CreateTimeout(string title, string detail) 87 | { 88 | var problemDetails = new ValidationProblemDetails 89 | { 90 | Title = title, 91 | Detail = detail, 92 | Status = 408 93 | }; 94 | 95 | return new ObjectResult(problemDetails) { StatusCode = 408 }; 96 | } 97 | 98 | /// 99 | /// Creates a standardized 409 Conflict response with a custom error message. 100 | /// 101 | /// The title of the error. 102 | /// The detailed error message. 103 | /// A with a validation problem details object. 104 | public static ConflictObjectResult CreateConflict(string title, string detail) 105 | { 106 | var problemDetails = new ValidationProblemDetails 107 | { 108 | Title = title, 109 | Detail = detail, 110 | Status = 409 111 | }; 112 | 113 | return new ConflictObjectResult(problemDetails); 114 | } 115 | 116 | /// 117 | /// Creates a standardized 500 Internal Server Error response with a custom error message. 118 | /// 119 | /// The title of the error. 120 | /// The detailed error message. 121 | /// An with a validation problem details object. 122 | public static ObjectResult CreateErrorResponse(string title, string detail, int statusCode = 500) 123 | { 124 | var problemDetails = new ValidationProblemDetails 125 | { 126 | Title = title, 127 | Detail = detail, 128 | Status = statusCode 129 | }; 130 | 131 | return new ObjectResult(problemDetails) { StatusCode = statusCode }; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Filters/EnumSchemaFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OpenApi.Any; 2 | using Microsoft.OpenApi.Models; 3 | using Swashbuckle.AspNetCore.SwaggerGen; 4 | 5 | namespace FeatureFusion.Infrastructure.Filters 6 | { 7 | public class EnumSchemaFilter : ISchemaFilter 8 | { 9 | public void Apply(OpenApiSchema schema, SchemaFilterContext context) 10 | { 11 | if (context.Type.IsEnum) 12 | { 13 | schema.Enum.Clear(); 14 | foreach (var name in Enum.GetNames(context.Type)) 15 | { 16 | schema.Enum.Add(new OpenApiString(name)); 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Filters/Evaluation.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.FeatureManagement; 2 | 3 | [FilterAlias("UseGreeting")] 4 | public class UseGreetingFilter : IFeatureFilter 5 | { 6 | private readonly IHttpContextAccessor _httpContext; 7 | 8 | public UseGreetingFilter(IHttpContextAccessor httpContext) 9 | { 10 | _httpContext = httpContext; 11 | } 12 | public Task EvaluateAsync(FeatureFilterEvaluationContext context) 13 | { 14 | 15 | 16 | var httpContext = _httpContext?.HttpContext; 17 | 18 | if (httpContext?.User?.Claims != null) 19 | { 20 | // Check if the user has a VIP claim and if the value is "true" 21 | var vipClaim = httpContext.User.Claims.FirstOrDefault(c => c.Type == "VIP"); 22 | 23 | if (vipClaim != null && vipClaim.Value.Equals("true", StringComparison.OrdinalIgnoreCase)) 24 | { 25 | // User is VIP 26 | return Task.FromResult(true); 27 | } 28 | 29 | // If the VIP claim is not present or not "true", return false 30 | return Task.FromResult(false); 31 | } 32 | 33 | return Task.FromResult(false); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Filters/IdempotentAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureFusion.Infrastructure.Filters 2 | { 3 | using FeatureFusion.Infrastructure.Caching; 4 | using Microsoft.AspNetCore.Mvc.Filters; 5 | using Microsoft.Extensions.Caching.Distributed; 6 | using Microsoft.Extensions.Caching.Hybrid; 7 | 8 | [AttributeUsage(AttributeTargets.Method)] 9 | public class IdempotentAttribute : Attribute, IFilterFactory 10 | { 11 | public bool UseLock { get; set; } 12 | 13 | public IdempotentAttribute(bool useLock = false) 14 | { 15 | UseLock = useLock; 16 | } 17 | public bool IsReusable => false; // Filters are not reusable 18 | 19 | public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) 20 | { 21 | var distributedCache = serviceProvider.GetService(); 22 | var redisWrapper = serviceProvider.GetService(); 23 | var loggerFactory = (ILoggerFactory)serviceProvider.GetService(typeof(ILoggerFactory)); 24 | 25 | return new IdempotentAttributeFilter( 26 | distributedCache, 27 | loggerFactory, 28 | redisWrapper, 29 | useLock: UseLock); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Filters/ValidationFilter.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.ValidationProvider; 2 | using FluentValidation; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | public class ValidationFilter : IEndpointFilter 7 | { 8 | private readonly IValidatorProvider _validatorProvider; 9 | 10 | public ValidationFilter(IValidatorProvider validatorProvider) 11 | { 12 | _validatorProvider = validatorProvider; 13 | } 14 | public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) 15 | { 16 | // Find the validator for the request model 17 | var validator = _validatorProvider.GetValidator(); 18 | 19 | if (validator != null) 20 | { 21 | // Extracting the request model from the context 22 | var requestModel = context.Arguments.OfType().FirstOrDefault(); 23 | 24 | if (requestModel != null) 25 | { 26 | // Creating a validation context for the request model 27 | var validationContext = new ValidationContext(requestModel); 28 | 29 | // Performing validation 30 | var validationResult = await validator.ValidateAsync(validationContext); 31 | 32 | if (!validationResult.IsValid) 33 | { 34 | // Returning validation errors as a problem details response 35 | return TypedResults.ValidationProblem(validationResult.ToDictionary()); 36 | } 37 | } 38 | } 39 | // If validation passes or no validator is found, proceed to the next middleware/endpoint 40 | return await next(context); 41 | } 42 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Initializers/AppInitializer.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureManagementFilters.Infrastructure.Initializers 2 | { 3 | public interface IAppInitializer 4 | { 5 | Task InitializeAsync(CancellationToken cancellationToken = default); 6 | } 7 | public sealed class AppInitializer(IServiceScopeFactory scopeFactory) 8 | : IHostedService 9 | { 10 | private readonly IServiceScopeFactory _scopeFactory = scopeFactory; 11 | 12 | public async Task StartAsync(CancellationToken cancellationToken) 13 | { 14 | using var scope = _scopeFactory.CreateScope(); 15 | 16 | var initializers = scope.ServiceProvider.GetServices(); 17 | 18 | foreach (var initializer in initializers) 19 | { 20 | // Run the initializers 21 | await initializer.InitializeAsync(cancellationToken); 22 | } 23 | } 24 | 25 | public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Initializers/ProductPromotionInitializer.cs: -------------------------------------------------------------------------------- 1 | using FeatureManagementFilters.Services.ProductService; 2 | using Microsoft.FeatureManagement; 3 | 4 | namespace FeatureManagementFilters.Infrastructure.Initializers 5 | { 6 | public class ProductPromotionInitializer : IAppInitializer 7 | { 8 | private readonly IServiceScopeFactory _serviceScopeFactory; 9 | IFeatureManagerSnapshot _featureManager; 10 | public ProductPromotionInitializer(IServiceScopeFactory serviceScopeFactory, 11 | IFeatureManagerSnapshot featureManager) 12 | { 13 | _serviceScopeFactory = serviceScopeFactory; 14 | _featureManager= featureManager; 15 | 16 | } 17 | public async Task InitializeAsync(CancellationToken cancellationToken = default) 18 | { 19 | if (await _featureManager.IsEnabledAsync("BackgroundServiceEnabled")) 20 | { 21 | // Execute background service logic 22 | Console.WriteLine("Background service is running..."); 23 | } 24 | else 25 | { 26 | Console.WriteLine("Background service is disabled..."); 27 | return ; 28 | } 29 | using var scope = _serviceScopeFactory.CreateScope(); 30 | var logger = scope.ServiceProvider.GetRequiredService>(); 31 | var productService = scope.ServiceProvider.GetRequiredService(); 32 | 33 | logger.LogInformation("==> Starting initialization of Product Promotions..."); 34 | 35 | try 36 | { 37 | // Read-through caching 38 | var promotions = await productService.GetProductPromotionAsync(false,cancellationToken); 39 | logger.LogInformation(" ==> Product Promotions initialized successfully."); 40 | } 41 | catch (Exception ex) 42 | { 43 | logger.LogError(ex, "Failed to initialize Product Promotions."); 44 | // Decide to fail or log and continue based on requirements 45 | throw; 46 | } 47 | 48 | } 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Middleware/MiddlewareCache.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | using FeatureManagementFilters.Infrastructure.Caching; 3 | using Microsoft.Extensions.Caching.Distributed; 4 | using System.Text.Json; 5 | 6 | public class RecommendationCacheMiddleware 7 | { 8 | private readonly RequestDelegate _next; 9 | private readonly IStaticCacheManager _cacheService; 10 | 11 | public RecommendationCacheMiddleware(RequestDelegate next, IStaticCacheManager cacheService) 12 | { 13 | _next = next; 14 | _cacheService = cacheService; 15 | } 16 | 17 | public async Task InvokeAsync(HttpContext context) 18 | { 19 | // Cache only GET requests 20 | if (context.Request.Path.Equals("/api/v2/product-recommendation") 21 | && context.Request.Method == HttpMethods.Get) 22 | { 23 | // Dynamically generate a cache key based on user-specific data 24 | var cacheKey = GenerateUserSpecificCacheKey(context); 25 | 26 | // Attempt to fetch cached data 27 | var cachedResponse = await _cacheService.TryGetAsync>(cacheKey) ?? []; 28 | if (cachedResponse is not null && cachedResponse.Count > 0) 29 | { 30 | context.Response.ContentType = "application/json"; 31 | await context.Response.WriteAsync(JsonSerializer.Serialize(cachedResponse)); // Serialize cachedResponse); 32 | return; 33 | } 34 | 35 | // Cache the response if not found 36 | await CacheRecommendationResponseAsync(context, cacheKey, TimeSpan.FromMinutes(10)); 37 | } 38 | else 39 | { 40 | await _next(context); 41 | return; 42 | } 43 | } 44 | 45 | private static CacheKey GenerateUserSpecificCacheKey(HttpContext context) 46 | { 47 | // Example: Combine user ID, path, and query into a unique cache key 48 | var userId = context.Request.Headers["X-User-Id"].ToString(); // Get user ID from headers 49 | var path = context.Request.Path.ToString(); 50 | var query = context.Request.QueryString.ToString(); 51 | 52 | return new CacheKey($"{userId}_{path}_{query}"); 53 | } 54 | 55 | private async Task CacheRecommendationResponseAsync(HttpContext context, CacheKey cacheKey, TimeSpan cacheDuration) 56 | { 57 | var originalResponseBody = context.Response.Body; 58 | using (var memoryStream = new MemoryStream()) 59 | { 60 | context.Response.Body = memoryStream; 61 | // next middleware in the request pipeline or the endpoint handler (fresh response) 62 | await _next(context); 63 | 64 | if (context.Response.StatusCode == StatusCodes.Status200OK) 65 | { 66 | memoryStream.Seek(0, SeekOrigin.Begin); 67 | var responseBody = await new StreamReader(memoryStream).ReadToEndAsync(); 68 | 69 | var recommendations = JsonSerializer.Deserialize>(responseBody); 70 | 71 | // Cache the deserialized object 72 | await _cacheService.SetAsync(cacheKey, recommendations); 73 | 74 | // Write the response back to the client 75 | memoryStream.Seek(0, SeekOrigin.Begin); 76 | await memoryStream.CopyToAsync(originalResponseBody); 77 | } 78 | else 79 | { 80 | memoryStream.Seek(0, SeekOrigin.Begin); 81 | await memoryStream.CopyToAsync(originalResponseBody); 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/Pipeline/ValidatePipeline.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Dtos; 2 | 3 | namespace FeatureManagementFilters.Pipeline; 4 | 5 | public class ValidationPipeline 6 | { 7 | private Func> _pipeline = user => Task.FromResult(true); // Default: Always passes 8 | 9 | public void AddRule(Func> rule) 10 | { 11 | var previous = _pipeline; 12 | _pipeline = async user => await previous(user) && await rule(user); // Chain with AND condition 13 | } 14 | 15 | public async Task ValidateAsync(UserDto user) => await _pipeline(user); 16 | } -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/ValidatorProvider/IValidatorProvider.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace FeatureFusion.Infrastructure.ValidationProvider 4 | { 5 | public interface IValidatorProvider 6 | { 7 | /// 8 | /// Retrieves a validator for the specified model type . 9 | /// 10 | /// The type of the model to validate. 11 | /// An instance of for the specified model type. 12 | IValidator GetValidator(); 13 | 14 | /// 15 | /// Retrieves a validator for the specified model type. 16 | /// 17 | /// The type of the model to validate. 18 | /// An instance of for the specified model type. 19 | IValidator GetValidatorForType(Type type); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/FeatureFusion/Infrastructure/ValidatorProvider/ValidatorProvider.cs: -------------------------------------------------------------------------------- 1 | using FeatureFusion.Infrastructure.ValidationProvider; 2 | using FeatureManagementFilters.Models.Validator; 3 | using FluentValidation; 4 | using System.Collections.Concurrent; 5 | using System.Reflection; 6 | 7 | 8 | /// 9 | /// Provides and caches validators for model types dynamically. 10 | /// 11 | public class ValidatorProvider : IValidatorProvider 12 | { 13 | private readonly IEnumerable _registeredValidators; 14 | private readonly ConcurrentDictionary _validatorCache = new(); 15 | 16 | /// 17 | /// Initializes the provider with a collection of registered validators. 18 | /// 19 | /// The available validators. 20 | public ValidatorProvider(IEnumerable registeredValidators) 21 | { 22 | _registeredValidators = registeredValidators; 23 | } 24 | 25 | /// 26 | /// Gets a validator for the specified model type. 27 | /// 28 | /// The model type. 29 | /// The corresponding validator. 30 | public IValidator GetValidator() => GetValidatorForType(typeof(TModel)); 31 | 32 | /// 33 | /// Gets a validator for a given model type. 34 | /// 35 | /// The model type. 36 | /// The corresponding validator. 37 | public IValidator GetValidatorForType(Type modelType) => _validatorCache.GetOrAdd(modelType, LocateValidatorForType); 38 | 39 | /// 40 | /// Finds a validator for the given model type. 41 | /// 42 | /// The model type. 43 | /// The validator or null if none found. 44 | /// Thrown if multiple validators exist for the same type. 45 | private IValidator LocateValidatorForType(Type modelType) 46 | { 47 | var validatorType = CreateValidatorTypeForModel(modelType); 48 | 49 | var matchingValidators = _registeredValidators 50 | .Where(v => validatorType.GetTypeInfo().IsInstanceOfType(v)) 51 | .ToArray(); 52 | 53 | if (matchingValidators.Length > 1) 54 | { 55 | var names = string.Join(", ", matchingValidators.Select(v => v.GetType().Name)); 56 | throw new InvalidOperationException($"Multiple validators found for '{modelType.Name}': {names}."); 57 | } 58 | 59 | return matchingValidators.FirstOrDefault(); 60 | } 61 | 62 | /// 63 | /// Constructs the expected validator type for a given model. 64 | /// 65 | /// The model type. 66 | /// The corresponding validator type. 67 | private static Type CreateValidatorTypeForModel(Type modelType) => 68 | typeof(AbstractValidator<>).MakeGenericType(modelType); 69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/FeatureFusion/Program.Testing.cs: -------------------------------------------------------------------------------- 1 | // Require a public Program class to implement the 2 | // fixture for the WebApplicationFactory in the 3 | // integration tests. Using IVT is not sufficient 4 | // in this case, because the accessibility of the 5 | // `Program` type is checked. 6 | public partial class Program { } 7 | -------------------------------------------------------------------------------- /src/FeatureFusion/Services/Authentication/AuthService.cs: -------------------------------------------------------------------------------- 1 | using FeatureManagementFilters.Services.Authentication; 2 | using Microsoft.IdentityModel.Tokens; 3 | using System.IdentityModel.Tokens.Jwt; 4 | using System.Security.Claims; 5 | using System.Text; 6 | 7 | 8 | public class AuthService : IAuthService 9 | { 10 | private readonly IConfiguration _configuration; 11 | 12 | public AuthService(IConfiguration configuration) 13 | { 14 | _configuration = configuration; 15 | } 16 | 17 | public bool ValidateVipUser(string username, string password) 18 | { 19 | // Add your VIP user validation logic here (e.g., check against DB) 20 | return username == "vipuser" && password == "vippassword"; 21 | } 22 | 23 | public string GenerateJwtToken(string username, bool isVip) 24 | { 25 | var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]); 26 | var claims = new List 27 | { 28 | new Claim(JwtRegisteredClaimNames.Sub, username), 29 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) 30 | }; 31 | 32 | if (isVip) 33 | { 34 | //just for test purpose ! 35 | claims.Add(new Claim("VIP", "true")); 36 | } 37 | 38 | var tokenDescriptor = new SecurityTokenDescriptor 39 | { 40 | Subject = new ClaimsIdentity(claims), 41 | Expires = DateTime.UtcNow.AddHours(1), 42 | SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature), 43 | Issuer = _configuration["Jwt:Issuer"], 44 | Audience = _configuration["Jwt:Audience"] 45 | }; 46 | 47 | var tokenHandler = new JwtSecurityTokenHandler(); 48 | var token = tokenHandler.CreateToken(tokenDescriptor); 49 | 50 | return tokenHandler.WriteToken(token); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/FeatureFusion/Services/Authentication/IAuthService.cs: -------------------------------------------------------------------------------- 1 | namespace FeatureManagementFilters.Services.Authentication 2 | { 3 | public interface IAuthService 4 | { 5 | bool ValidateVipUser(string username, string password); 6 | string GenerateJwtToken(string username, bool isVip); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/FeatureFusion/Services/FeatureToggle/FeatureToggleService.cs: -------------------------------------------------------------------------------- 1 |  2 | using FeatureFusion.Dtos; 3 | using FeatureManagementFilters.Pipeline; 4 | using Microsoft.FeatureManagement; 5 | 6 | namespace FeatureManagementFilters.Services.FeatureToggleService 7 | { 8 | public class FeatureToggleService : IFeatureToggleService 9 | { 10 | private readonly ValidationPipeline _validateRules ; 11 | private readonly IFeatureManagerSnapshot _featureManager; 12 | 13 | public FeatureToggleService(IFeatureManagerSnapshot featureManager) 14 | { 15 | _validateRules = new ValidationPipeline(); 16 | _featureManager = featureManager; 17 | } 18 | 19 | public async Task CanAccessFeatureAsync(UserDto user) 20 | { 21 | _validateRules.AddRule(async user => await _featureManager.IsEnabledAsync("CustomGreeting")); 22 | _validateRules.AddRule(user => Task.FromResult(user.Role == "Amin")); 23 | _validateRules.AddRule(user => Task.FromResult(user.HasActiveSubscription)); 24 | return await _validateRules.ValidateAsync(user); 25 | } 26 | } 27 | public interface IFeatureToggleService 28 | { 29 | Task CanAccessFeatureAsync(UserDto user); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/FeatureFusion/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt": { 3 | "Key": "YourSuperSecretKeyWithAtLeast32CharactersLong", 4 | "Issuer": "yourdomain.com", 5 | "Audience": "yourdomain.com" 6 | }, 7 | "FeatureManagement": { 8 | "CustomGreeting": { // The "GreetingFeature" is the name of the feature flag. 9 | "EnabledFor": [ 10 | { 11 | "Name": "UseGreeting" //we use evaluate filter to check this 12 | } 13 | ] 14 | }, 15 | "RecommendationCacheMiddleware": false, 16 | "BackgroundServiceEnabled": true, 17 | "MemCachedEnabled": true, 18 | "IdempotencyEnabled": true 19 | }, 20 | "Redis": { 21 | "ConnectionString": "localhost:6379", 22 | "InstanceName": "MyApp:" 23 | }, 24 | "Memcached": { 25 | "Servers": [ 26 | { 27 | "Address": "localhost", 28 | "Port": 11211 29 | } 30 | ], 31 | "SocketPool": { 32 | "MinPoolSize": 5, 33 | "MaxPoolSize": 100, 34 | "ConnectionTimeout": "00:00:10" 35 | } 36 | }, 37 | "Logging": { 38 | "LogLevel": { 39 | "Default": "Information", 40 | "Microsoft.AspNetCore": "Warning", 41 | "Enyim.Caching": "Debug", 42 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 43 | } 44 | }, 45 | "AllowedHosts": "*", 46 | "ConnectionStrings": { 47 | "RabbitMQ": "amqp://guest:guest@eventbus:5672/", 48 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 49 | }, 50 | "EventBus": { 51 | "EnableDeduplication": false, 52 | "SubscriptionClientName": "feature_fusion", 53 | "RetryCount": 10 54 | 55 | }, 56 | "Aspire": { 57 | "Npgsql": { 58 | "EntityFrameworkCore": { 59 | "PostgreSQL": { 60 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/FeatureFusion/appsettings.Production.Test.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt": { 3 | "Key": "YourSuperSecretKeyWithAtLeast32CharactersLong", 4 | "Issuer": "yourdomain.com", 5 | "Audience": "yourdomain.com" 6 | }, 7 | "FeatureManagement": { 8 | "CustomGreeting": { // The "GreetingFeature" is the name of the feature flag. 9 | "EnabledFor": [ 10 | { 11 | "Name": "UseGreeting" //we use evaluate filter to check this 12 | } 13 | ] 14 | }, 15 | "RecommendationCacheMiddleware": false, 16 | "BackgroundServiceEnabled": true, 17 | "MemCachedEnabled": true, 18 | "IdempotencyEnabled": true 19 | }, 20 | "Redis": { 21 | "ConnectionString": "172.18.0.3:6379", 22 | "InstanceName": "MyApp:" 23 | }, 24 | "Memcached": { 25 | "Servers": [ 26 | { 27 | "Address": "memcachedTest", 28 | "Port": 11211 29 | } 30 | ], 31 | "SocketPool": { 32 | "MinPoolSize": 5, 33 | "MaxPoolSize": 100, 34 | "ConnectionTimeout": "00:00:10" 35 | } 36 | }, 37 | "Logging": { 38 | "LogLevel": { 39 | "Default": "Information", 40 | "Microsoft.AspNetCore": "Warning", 41 | "Enyim.Caching": "Debug", 42 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 43 | } 44 | }, 45 | "AllowedHosts": "*", 46 | "ConnectionStrings": { 47 | "eventbus": "amqp://guest:guest@eventbus:5672/", 48 | "catalogdb": "Host=postgresTest;Port=5432;Username=username;Password=password;Database=eventstore;" 49 | }, 50 | "EventBus": { 51 | "EnableDeduplication": false, 52 | "SubscriptionClientName": "feature_fusion", 53 | "RetryCount": 10 54 | 55 | }, 56 | "Aspire": { 57 | "Npgsql": { 58 | "EntityFrameworkCore": { 59 | "PostgreSQL": { 60 | "catalogdb": "Host=postgres;Port=5432;Username=username;Password=password;Database=eventstore;" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/FeatureFusion/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt": { 3 | "Key": "YourSuperSecretKeyWithAtLeast32CharactersLong", 4 | "Issuer": "yourdomain.com", 5 | "Audience": "yourdomain.com" 6 | }, 7 | "FeatureManagement": { 8 | "CustomGreeting": { // The "GreetingFeature" is the name of the feature flag. 9 | "EnabledFor": [ 10 | { 11 | "Name": "UseGreeting" //we use evaluate filter to check this 12 | } 13 | ] 14 | }, 15 | "RecommendationCacheMiddleware": false, 16 | "BackgroundServiceEnabled": true, 17 | "MemCachedEnabled": true, 18 | "IdempotencyEnabled": true 19 | }, 20 | "Redis": { 21 | "ConnectionString": "redis:6379", 22 | "InstanceName": "MyApp:" 23 | }, 24 | "Memcached": { 25 | "Servers": [ 26 | { 27 | "Address": "memcached", 28 | "Port": 11211 29 | } 30 | ], 31 | "SocketPool": { 32 | "MinPoolSize": 5, 33 | "MaxPoolSize": 100, 34 | "ConnectionTimeout": "00:00:10" 35 | } 36 | }, 37 | "Logging": { 38 | "LogLevel": { 39 | "Default": "Information", 40 | "Microsoft.AspNetCore": "Warning", 41 | "Enyim.Caching": "Debug", 42 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 43 | } 44 | }, 45 | "AllowedHosts": "*", 46 | "ConnectionStrings": { 47 | "eventbus": "amqp://guest:guest@eventbus:5672/", 48 | "catalogdb": "Host=postgres;Port=5432;Username=username;Password=password;Database=eventstore;" 49 | }, 50 | "EventBus": { 51 | "EnableDeduplication": false, 52 | "SubscriptionClientName": "feature_fusion", 53 | "RetryCount": 10 54 | 55 | }, 56 | "Aspire": { 57 | "Npgsql": { 58 | "EntityFrameworkCore": { 59 | "PostgreSQL": { 60 | "catalogdb": "Host=postgres;Port=5432;Username=username;Password=password;Database=eventstore;" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/FeatureFusion/appsettings.Test.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt": { 3 | "Key": "YourSuperSecretKeyWithAtLeast32CharactersLong", 4 | "Issuer": "yourdomain.com", 5 | "Audience": "yourdomain.com" 6 | }, 7 | "FeatureManagement": { 8 | "CustomGreeting": { // The "GreetingFeature" is the name of the feature flag. 9 | "EnabledFor": [ 10 | { 11 | "Name": "UseGreeting" //we use evaluate filter to check this 12 | } 13 | ] 14 | }, 15 | "RecommendationCacheMiddleware": false, 16 | "BackgroundServiceEnabled": true, 17 | "MemCachedEnabled": true, 18 | "IdempotencyEnabled": true 19 | }, 20 | "Redis": { 21 | "ConnectionString": "localhost:6379", 22 | "InstanceName": "MyApp:" 23 | }, 24 | "Memcached": { 25 | "Servers": [ 26 | { 27 | "Address": "localhost", 28 | "Port": 11211 29 | } 30 | ], 31 | "SocketPool": { 32 | "MinPoolSize": 5, 33 | "MaxPoolSize": 100, 34 | "ConnectionTimeout": "00:00:10" 35 | } 36 | }, 37 | "Logging": { 38 | "LogLevel": { 39 | "Default": "Information", 40 | "Microsoft.AspNetCore": "Warning", 41 | "Enyim.Caching": "Debug", 42 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 43 | } 44 | }, 45 | "AllowedHosts": "*", 46 | "ConnectionStrings": { 47 | "RabbitMQ": "amqp://guest:guest@localhost:5672/", 48 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 49 | }, 50 | "EventBus": { 51 | "EnableDeduplication": false, 52 | "SubscriptionClientName": "feature_fusion", 53 | "RetryCount": 10 54 | 55 | }, 56 | "Aspire": { 57 | "Npgsql": { 58 | "EntityFrameworkCore": { 59 | "PostgreSQL": { 60 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/FeatureFusion/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Jwt": { 3 | "Key": "YourSuperSecretKeyWithAtLeast32CharactersLong", 4 | "Issuer": "yourdomain.com", 5 | "Audience": "yourdomain.com" 6 | }, 7 | "FeatureManagement": { 8 | "CustomGreeting": { // The "GreetingFeature" is the name of the feature flag. 9 | "EnabledFor": [ 10 | { 11 | "Name": "UseGreeting" //we use evaluate filter to check this 12 | } 13 | ] 14 | }, 15 | "RecommendationCacheMiddleware": false, 16 | "BackgroundServiceEnabled": true, 17 | "MemCachedEnabled": true, 18 | "IdempotencyEnabled": true 19 | }, 20 | "Redis": { 21 | "ConnectionString": "localhost:6379", 22 | "InstanceName": "MyApp:" 23 | }, 24 | "Memcached": { 25 | "Servers": [ 26 | { 27 | "Address": "localhost", 28 | "Port": 11211 29 | } 30 | ], 31 | "SocketPool": { 32 | "MinPoolSize": 5, 33 | "MaxPoolSize": 100, 34 | "ConnectionTimeout": "00:00:10" 35 | } 36 | }, 37 | "Logging": { 38 | "LogLevel": { 39 | "Default": "Information", 40 | "Microsoft.AspNetCore": "Warning", 41 | "Enyim.Caching": "Debug", 42 | "Enyim.Caching.Memcached": "Trace" // Full protocol tracing 43 | } 44 | }, 45 | "AllowedHosts": "*", 46 | "ConnectionStrings": { 47 | "RabbitMQ": "amqp://guest:guest@eventbus:5672/", 48 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 49 | }, 50 | "EventBus": { 51 | "EnableDeduplication": false, 52 | "SubscriptionClientName": "feature_fusion", 53 | "RetryCount": 10 54 | 55 | }, 56 | "Aspire": { 57 | "Npgsql": { 58 | "EntityFrameworkCore": { 59 | "PostgreSQL": { 60 | "catalogdb": "Host=localhost;Port=5432;Username=username;Password=password;Database=eventstore;" 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/FeatureManagementFilters.http: -------------------------------------------------------------------------------- 1 | @FeatureManagementFilters_HostAddress = https://localhost:7226 2 | 3 | GET {{FeatureManagementFilters_HostAddress}}/api/v1/Greeting/custom-greeting 4 | 5 | ### 6 | -------------------------------------------------------------------------------- /tests/EventBus.Test/EventBus.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | net9.0 6 | false 7 | false 8 | true 9 | true 10 | true 11 | true 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | all 32 | runtime; build; native; contentfiles; analyzers; buildtransitive 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/EventBus.Test/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.IO; 2 | global using System.Net; 3 | global using System.Threading.Tasks; 4 | global using Microsoft.AspNetCore.Hosting; 5 | global using Microsoft.AspNetCore.TestHost; 6 | global using Microsoft.Extensions.Configuration; 7 | global using Microsoft.Extensions.Hosting; 8 | global using Xunit; 9 | -------------------------------------------------------------------------------- /tests/EventBus.Test/config/rabbitmq.conf: -------------------------------------------------------------------------------- 1 | # config/rabbitmq.conf 2 | 3 | # Disable SSL (TLS) 4 | listeners.ssl.default = 0 5 | management.ssl.port = 0 -------------------------------------------------------------------------------- /tests/EventBus.Test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | memcachedTest: 5 | image: memcached:alpine 6 | container_name: memcachedTest 7 | ports: 8 | - "11211:11211" 9 | healthcheck: 10 | test: ["CMD", "nc", "-z", "memcachedTest", "11211"] 11 | interval: 5s 12 | timeout: 3s 13 | retries: 5 14 | networks: 15 | - eventbus-network 16 | 17 | redisTest: 18 | image: redis:latest 19 | container_name: redisTest 20 | ports: 21 | - "6379:6379" 22 | command: redis-server --bind 0.0.0.0 23 | volumes: 24 | - redis_test_data:/data 25 | healthcheck: 26 | test: ["CMD", "redis-cli", "-h", "redisTest", "ping"] 27 | interval: 5s 28 | timeout: 3s 29 | retries: 5 30 | networks: 31 | - eventbus-network 32 | 33 | eventbusTest: 34 | image: rabbitmq:management 35 | container_name: eventbusTest 36 | hostname: eventbusTest 37 | ports: 38 | - "5672:5672" 39 | - "15672:15672" 40 | environment: 41 | RABBITMQ_DEFAULT_USER: guest 42 | RABBITMQ_DEFAULT_PASS: guest 43 | RABBITMQ_DEFAULT_VHOST: / 44 | healthcheck: 45 | test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] 46 | interval: 10s 47 | timeout: 5s 48 | retries: 5 49 | volumes: 50 | - rabbitmq_test_data:/var/lib/rabbitmq 51 | networks: 52 | - eventbus-network 53 | 54 | postgresTest: 55 | image: postgres:15-alpine 56 | container_name: postgresTest 57 | ports: 58 | - "5432:5432" 59 | environment: 60 | POSTGRES_USER: username 61 | POSTGRES_PASSWORD: password 62 | POSTGRES_DB: eventstore 63 | volumes: 64 | - postgres_test_data:/var/lib/postgresql/data 65 | healthcheck: 66 | test: ["CMD-SHELL", "pg_isready -h postgresTest -U username -d eventstore"] 67 | interval: 5s 68 | timeout: 5s 69 | retries: 10 70 | networks: 71 | - eventbus-network 72 | 73 | pgadminTest: 74 | image: dpage/pgadmin4 75 | container_name: pgadminTest 76 | ports: 77 | - "5050:80" 78 | environment: 79 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 80 | PGADMIN_DEFAULT_PASSWORD: admin 81 | depends_on: 82 | postgresTest: 83 | condition: service_healthy 84 | networks: 85 | - eventbus-network 86 | 87 | volumes: 88 | redis_test_data: 89 | postgres_test_data: 90 | rabbitmq_test_data: 91 | 92 | networks: 93 | eventbus-network: 94 | driver: bridge -------------------------------------------------------------------------------- /tests/FeatureFusion.ApiGateway.Test/FeatureFusion.ApiGateway.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/FeatureFusion.Common/FeatureFusion.Common.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/FeatureFusion.Common/Shared/MemcachedFixture.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Enyim.Caching; 4 | using Microsoft.Extensions.Logging; 5 | using DotNet.Testcontainers.Containers; 6 | using Microsoft.Extensions.Options; 7 | using DotNet.Testcontainers.Builders; 8 | using DotNet.Testcontainers.Configurations; 9 | using Enyim.Caching.Configuration; 10 | using Xunit; 11 | 12 | public sealed class MemcachedFixture : IAsyncLifetime 13 | { 14 | private readonly IContainer _memcachedContainer; 15 | private MemcachedClient _memcachedClient = null!; 16 | private readonly ILoggerFactory _loggerFactory; 17 | 18 | public MemcachedClient MemcachedClient => _memcachedClient; 19 | public ILogger Logger { get; private set; } 20 | 21 | public MemcachedFixture() 22 | { 23 | _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); 24 | Logger = _loggerFactory.CreateLogger(); 25 | 26 | _memcachedContainer = new ContainerBuilder() 27 | .WithImage("memcached:latest") 28 | .WithPortBinding(11211, assignRandomHostPort: true) 29 | .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(11211)) 30 | .Build(); 31 | } 32 | 33 | public async Task InitializeAsync() 34 | { 35 | await _memcachedContainer.StartAsync(); 36 | var port = _memcachedContainer.GetMappedPublicPort(11211); 37 | 38 | var options = Options.Create(new MemcachedClientOptions 39 | { 40 | Servers = { new Server { Address = "localhost", Port = port } } 41 | }); 42 | 43 | var config = new MemcachedClientConfiguration( 44 | _loggerFactory, 45 | options, 46 | configuration: null, 47 | transcoder: null, 48 | keyTransformer: null); 49 | 50 | _memcachedClient = new MemcachedClient(_loggerFactory, config); 51 | } 52 | 53 | public async Task DisposeAsync() 54 | { 55 | 56 | // Dispose the client properly to clean up resources after tests 57 | _memcachedClient?.Dispose(); 58 | 59 | await _memcachedContainer.DisposeAsync(); 60 | } 61 | 62 | public MemcachedCacheManager CreateCacheManager() => 63 | new MemcachedCacheManager(_memcachedClient, Logger); 64 | 65 | public async Task ClearCacheAsync() 66 | { 67 | 68 | try 69 | { 70 | await _memcachedClient.FlushAllAsync(); 71 | } 72 | catch 73 | { 74 | // memcached disposed 75 | //throw new Exception("Failed to clear cache.", ex); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /tests/FeatureFusion.Test/FeatureFusion.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | latest 6 | enable 7 | enable 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/FeatureFusion.Test/MemcachedTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Xunit; 4 | using Enyim.Caching; 5 | using Microsoft.Extensions.Logging; 6 | using DotNet.Testcontainers.Containers; 7 | using Microsoft.Extensions.Options; 8 | using DotNet.Testcontainers.Builders; 9 | using DotNet.Testcontainers.Configurations; 10 | using Enyim.Caching.Configuration; 11 | using FeatureManagementFilters.Infrastructure.Caching; 12 | namespace Tests.FeatureFusion.UnitTest 13 | { 14 | public sealed class MemcachedCacheManagerTests : IClassFixture, IAsyncLifetime 15 | { 16 | private readonly MemcachedFixture _fixture; 17 | private MemcachedCacheManager _cacheManager = null!; 18 | 19 | public MemcachedCacheManagerTests(MemcachedFixture fixture) 20 | { 21 | _fixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); 22 | } 23 | 24 | public async Task InitializeAsync() 25 | { 26 | _cacheManager = _fixture.CreateCacheManager(); 27 | await _fixture.ClearCacheAsync(); 28 | } 29 | 30 | public Task DisposeAsync() => Task.CompletedTask; 31 | 32 | [Fact] 33 | public async Task ShouldRetrieveData_FromMemcached() 34 | { 35 | // Arrange 36 | var key = new CacheKey("test-key"); 37 | var expectedValue = "hello-world"; 38 | 39 | await _fixture.MemcachedClient.SetAsync(key.Key, expectedValue, TimeSpan.FromMinutes(1)); 40 | 41 | // Act 42 | var result = await _cacheManager.GetValueOrCreateAsync(key, () => Task.FromResult("db-data")); 43 | 44 | // Assert 45 | Assert.Equal(expectedValue, result); 46 | } 47 | 48 | [Fact] 49 | public async Task ShouldFallbackToDB_WhenCacheMiss() 50 | { 51 | // Arrange 52 | var key = new CacheKey("missing-key"); 53 | 54 | // Act 55 | var result = await _cacheManager.GetValueOrCreateAsync(key, () => Task.FromResult("db-data")); 56 | 57 | // Assert 58 | Assert.Equal("db-data", result); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /waif-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi --------------------------------------------------------------------------------