From b5b9ef4ac4d247392d83165e5701007dcec64dd2 Mon Sep 17 00:00:00 2001 From: Mehdi Hadeli Date: Sat, 20 Jul 2024 18:32:06 +0200 Subject: [PATCH] refactor: upgrade to .net 8 and refactoring #226 --- .bash_aliases | 162 + .config/dotnet-tools.json | 44 +- .devcontainer/Dockerfile | 11 +- .devcontainer/devcontainer.json | 147 +- .devcontainer/docker-compose.yaml | 26 + .devcontainer/scripts/post-create.sh | 6 + .devcontainer/scripts/setup-fonts.sh | 24 + .devcontainer/scripts/update.sh | 3 + .editorconfig | 3 + .github/actions/build-test/action.yml | 8 +- .github/actions/docker-build-push/action.yml | 15 +- .github/actions/test/action.yml | 6 +- .github/dependabot.yml | 9 +- .github/workflows/catalogs.yml | 403 + .github/workflows/customers.yml | 404 + .github/workflows/first-interaction.yml | 12 +- .github/workflows/gateway.yml | 388 + .github/workflows/identity.yml | 404 + .github/workflows/labeler.yml | 17 +- .../workflows/reusable-build-test-push.yml | 6 +- .github/workflows/reusable-build-test.yml | 6 +- .gitignore | 10 +- .gitpod.yml | 123 + .husky/pre-commit | 2 +- .vscode/launch.json | 854 +++ .vscode/settings.json | 21 + .vscode/tasks.json | 1024 +++ _httpclients/catalogs/catalogs.rest | 92 + _httpclients/customers/customers.rest | 109 + _httpclients/http-client.env.json | 8 + _httpclients/identity/identity.rest | 91 + assets/catalog-service.png | Bin 0 -> 35861 bytes assets/header.png | Bin 0 -> 30884 bytes assets/level-structure.png | Bin 0 -> 22220 bytes .../docker-compose/.env.catalogs.sample | 2 + .../docker-compose/.env.cutomers.sample | 2 + deployments/docker-compose/.env.dev.sample | 0 .../docker-compose/.env.identity.sample | 2 + deployments/docker-compose/.env.prod | 2 + deployments/docker-compose/.env.prod.sample | 0 deployments/docker-compose/.env.sample | 46 + .../docker-compose.infrastructure.yaml | 395 +- .../docker-compose.override.yaml | 122 + .../docker-compose.services-win.yaml | 147 + .../docker-compose.services.debug-win.yaml | 224 + .../docker-compose.services.debug.yaml | 224 + .../docker-compose.services.dev-win.yaml | 167 + .../docker-compose.services.dev.yaml | 167 + .../docker-compose.services.prod.yaml | 62 + .../docker-compose.services.yaml | 147 + .../docker-compose/docker-compose.yaml | 390 + deployments/docker-compose/docker.txt | 2 +- .../docker-compose/monitoring/prometheus.yml | 12 + deployments/tye/tye.yaml | 46 + food-delivery-microservices.sln | 530 ++ global.json | 4 +- nuget.config | 8 + package-lock.json | 6715 ++++++++++------- package.json | 20 +- pm2.json | 11 + pm2.yaml | 26 + readme.md | 33 +- scripts/docker/base-run.sh | 2 +- scripts/docker/debug-run.sh | 5 +- scripts/docker/dev-run.sh | 3 +- scripts/docker/prod-run.sh | 3 +- src/ApiGateway/Directory.Build.props | 13 + src/ApiGateway/Dockerfile | 71 + .../FoodDelivery.ApiGateway.csproj | 37 + .../FoodDelivery.ApiGateway/Program.cs | 65 + .../Properties/launchSettings.json | 49 + .../appsettings.docker.json | 30 + .../FoodDelivery.ApiGateway/appsettings.json | 131 + src/ApiGateway/dev.Dockerfile | 74 + src/ApiGateway/watch.Dockerfile | 28 + .../BuildingBlocks.Abstractions.csproj | 2 +- .../CQRS/Commands/ICommand.cs | 2 +- .../CQRS/Commands/ICommandHandler.cs | 4 +- .../CQRS/Commands/ICommandProcessor.cs | 5 +- .../CQRS/Commands/IInternalCommandMapper.cs | 2 +- .../CQRS/Commands/ITxCommand.cs | 2 +- .../CQRS/Queries/IPageQuery.cs | 6 + .../Caching/ICacheQuery.cs | 7 + .../Caching/IInvalidateCacheRequest.cs | 7 + .../Core/Paging/IPageList.cs | 24 + .../Core/Paging/IPageRequest.cs | 9 + .../Domain/AggregateId.cs | 7 +- .../Domain/EntityId.cs | 18 +- .../IAggregatesDomainEventsRequestStore.cs | 13 + .../Domain/Events/IDomainEventsAccessor.cs | 8 + .../Domain/Events/IEvent.cs | 31 + .../Domain/Events/IEventHandler.cs | 6 + .../Domain/Events/IEventMapper.cs | 18 + .../Domain/Events/IHaveDomainEvents.cs | 28 + .../Domain/Events/IInternalEventBus.cs | 15 + .../Domain/Events/Internal/IDomainEvent.cs | 15 + .../Events/Internal/IDomainEventContext.cs | 7 + .../Events/Internal/IDomainEventHandler.cs | 4 + .../Events/Internal/IDomainEventPublisher.cs | 7 + .../Internal/IDomainNotificationEvent.cs | 9 + .../IDomainNotificationEventHandler.cs | 4 + .../IDomainNotificationEventPublisher.cs | 11 + .../Events/Internal/IHaveNotificationEvent.cs | 3 + .../Domain/IBusinessRuleWithExceptionType.cs | 15 + .../Domain/IHaveAggregate.cs | 11 +- .../Messaging/IIntegrationEventHandler.cs | 2 +- .../IMessagePersistenceService.cs | 5 +- .../EventStore/IHaveEventSourcingAggregate.cs | 2 +- .../Persistence/EventStore/IStreamEvent.cs | 12 +- .../EventStore/IStreamEventMetadata.cs | 7 +- .../Projections/IHaveReadProjection.cs | 2 +- .../Projections/IReadProjectionPublisher.cs | 2 +- .../Persistence/IDbExecutors.cs | 6 + .../Persistence/IEventRepository.cs | 2 +- .../Persistence/IMigrationExecutor.cs | 6 + .../Persistence/IMigrationManager.cs | 6 + .../Persistence/IRepository.cs | 28 + .../Web/MinimalApi/IHttpCommand.cs | 27 + .../Web/MinimalApi/IHttpQuery.cs | 18 + .../Web/MinimalApi/IMinimalEndpoint.cs | 132 +- .../Web/Problem/IProblemDetailMapper.cs | 6 + .../Behaviours/CachingBehavior.cs | 102 +- .../Behaviours/InvalidateCachingBehavior.cs | 72 +- .../BuildingBlocks.Caching.csproj | 1 + .../BuildingBlocks.Caching/CacheKey.cs | 2 - .../BuildingBlocks.Caching/CacheOptions.cs | 2 +- .../BuildingBlocks.Caching/CacheQuery.cs | 17 + .../DatabaseExtensions.cs | 74 + .../BuildingBlocks.Caching/Extensions.cs | 84 +- .../BuildingBlocks.Caching/RedisOptions.cs | 7 + .../BuildingBlocks.Core.csproj | 10 +- .../CQRS/Commands/CommandProcessor.cs | 15 +- .../CQRS/Queries/PageQuery.cs | 8 + .../BuildingBlocks.Core/Domain/Aggregate.cs | 25 +- .../BuildingBlocks.Core/Domain/Entity.cs | 2 +- .../EventSourcing/EventSourcedAggregate.cs | 25 +- .../Events/AggregateDomainEventsStore.cs | 33 + .../Domain/Events/DomainEventAccessor.cs | 30 + .../Domain/Events/DomainEventPublisher.cs | 224 + .../DomainNotificationEventPublisher.cs | 38 + .../Domain/Events/Event.cs | 13 + .../Domain/Events/EventHandlerDecorator.cs | 21 + .../Domain/Events/EventsExtensions.cs | 122 + .../Domain/Events/Internal/DomainEvent.cs | 17 + .../Events/Internal/DomainEventsInvoker.cs | 40 + .../Internal/DomainNotificationEvent.cs | 5 + .../DomainNotificationEventWrapper.cs | 6 + .../Internal/IntegrationEventWrapper.cs | 7 + .../Events/Internal/NotificationEvent.cs | 4 + .../Domain/Events/InternalEventBus.cs | 62 + .../Domain/Exceptions/DomainException.cs | 26 +- .../Exceptions/InvalidAmountException.cs | 14 + .../Exceptions/InvalidCurrencyException.cs | 14 + .../Domain/Exceptions/InvalidDateException.cs | 14 + .../Exceptions/InvalidEmailException.cs | 14 + .../Exceptions/InvalidPhoneNumberException.cs | 14 + .../Domain/ValueObjects/Address.cs | 29 +- .../Domain/ValueObjects/Amount.cs | 23 +- .../Domain/ValueObjects/BirthDate.cs | 17 + .../Domain/ValueObjects/Currency.cs | 18 +- .../Domain/ValueObjects/Email.cs | 17 +- .../Domain/ValueObjects/MobileNumber.cs | 37 + .../Domain/ValueObjects/Money.cs | 20 +- .../Domain/ValueObjects/PhoneNumber.cs | 19 +- .../Exception/Types/AppException.cs | 13 +- .../Exception/Types/BadRequestException.cs | 9 +- .../Exception/Types/ConflictException.cs | 26 +- .../Exception/Types/CustomException.cs | 17 +- .../Exception/Types/ForbiddenException.cs | 6 +- .../Exception/Types/HttpResponseException.cs | 25 +- .../Exception/Types/IdentityException.cs | 7 +- .../Exception/Types/NotFoundException.cs | 25 +- .../Exception/Types/UnAuthorizedException.cs | 6 +- .../Exception/Types/ValidationException.cs | 10 + .../Extensions/ConfigurationExtensions.cs | 42 + .../HostApplicationLifetimeExtensions.cs | 24 + .../Extensions/QueryableExtensions.cs | 158 + .../ServiceCollectionExtensions.Dependency.cs | 317 + .../ServiceCollectionExtensions.Options.cs | 135 + .../Extensions/ValidationExtensions.cs | 313 + .../IdsGenerator/SnowFlakIdGenerator.cs | 33 + .../MessagePersistenceWorker.cs | 81 + .../Messaging/IntegrationEvent.cs | 2 +- .../BuildingBlocks.Core/Messaging/Message.cs | 2 +- .../InMemoryMessagePersistenceRepository.cs | 8 +- .../MessagePersistenceService.cs | 6 +- .../Paging/ApplicationSieveProcessor.cs | 31 + .../BuildingBlocks.Core/Paging/PageList.cs | 34 + .../BuildingBlocks.Core/Paging/PageRequest.cs | 41 + .../Persistence/EfCore/EfDbContextBase.cs | 4 +- .../Persistence/EfCore/EfRepository.cs | 30 + .../Persistence/EfCore/EfRepositoryBase.cs | 134 +- .../Persistence/EfCore/EfTxBehavior.cs | 2 +- .../Persistence/EfCore/EfUnitOfWork.cs | 6 +- .../Interceptors/ConcurrencyInterceptor.cs | 1 + .../Persistence/EventStore/AggregateStore.cs | 24 +- .../EventStoreDomainEventAccessor.cs | 17 + .../EventStore/ProjectionPublisher.cs | 2 +- .../Persistence/EventStore/StreamEvent.cs | 23 +- .../StreamEventDataSerializationExtensions.cs | 4 +- .../EventStore/StreamEventExtensions.cs | 2 +- .../EventStore/StreamEventMetadata.cs | 11 +- .../Persistence/EventStore/StreamName.cs | 17 +- .../Persistence/MigrationManager.cs | 39 + .../Persistence/SeedWorker.cs | 56 + .../Reflection/Extensions/TypeExtensions.cs | 6 +- .../CQRSRegistrationRegistrationExtensions.cs | 58 + .../CoreRegistrationExtensions.cs | 71 +- .../DbExecutorRegistrationsExtensions.cs | 35 + .../EventStoreRegistrationExtensions.cs | 69 + ...InMemoryMessagingRegistrationExtensions.cs | 2 +- .../NoSynchronizationContextScope.cs | 23 + .../Types/Extensions/StringExtensions.cs | 20 + .../Types/MachineInstanceInfo.cs | 7 +- .../BuildingBlocks.Core/Types/TypeMapper.cs | 11 +- .../BuildingBlocks.Email/Extensions.cs | 6 +- .../BuildingBlocks.Email/IEmailSender.cs | 18 +- .../SendGridEmailSender.cs | 8 +- .../BuildingBlocks.HealthCheck.csproj | 6 + .../BuildingBlocks.HealthCheck/Extensions.cs | 41 +- .../Extensions.cs | 7 +- .../BuildingBlocks.Logging.csproj | 3 + .../Extensions/RegistrationExtensions.cs | 43 +- .../BuildingBlocks.Logging/LoggingBehavior.cs | 113 +- .../BuildingBlocks.Logging/SerilogOptions.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 37 +- .../Extensions.cs | 100 +- .../NpgsqlConnectionFactory.cs | 7 +- .../ServiceCollectionExtensions.cs | 125 +- ...dingBlocks.Persistence.EventStoreDB.csproj | 8 + .../EventStoreDbEventStore.cs | 199 + .../EventStoreDbOptions.cs | 35 + .../Extensions/EventStoreClientExtensions.cs | 65 + .../Extensions/RegistrationExtensions.cs | 49 + .../Extensions/SerializationExtensions.cs | 60 + .../Extensions/StreamEventExtensions.cs | 31 + .../Extensions/StreamRevisionExtensions.cs | 16 + ...StoreDbSubscriptionCheckPointRepository.cs | 79 + .../ISubscriptionCheckpointRepository.cs | 8 + ...nMemorySubscriptionCheckpointRepository.cs | 20 + .../SubscribeToAllBackgroundWorker.cs | 219 + .../readme.md | 0 .../BuildingBlocks.Persistence.Marten.csproj | 12 + .../Extensions/ServiceCollectionExtensions.cs | 79 + .../MartenEventStore.cs | 143 + .../MartenIdGenerator.cs | 8 + .../MartenOptions.cs | 15 + .../Subscriptions/IMartenEventsConsumer.cs | 13 + .../Subscriptions/MartenEventPublisher.cs | 27 + .../Subscriptions/MartenSubscription.cs | 41 + .../readme.md | 0 .../Extensions/ServiceCollectionExtensions.cs | 110 + .../MongoDbContext.cs | 31 +- .../MongoRepositoryBase.cs | 190 + .../MongoUnitOfWork.cs | 5 +- ...pClientBuilderExtensions.CircuitBreaker.cs | 4 +- .../HttpClientBuilderExtensions.Retry.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../PolicyOptions.cs | 6 +- .../Extensions/ApiKeyExtensions.cs | 9 +- .../Extensions/JwtExtensions.cs | 63 +- .../BuildingBlocks.Security/Jwt/JwtOptions.cs | 2 +- .../BuildingBlocks.Security/Jwt/JwtService.cs | 40 +- .../BuildingBlocks.Swagger/Extensions.cs | 34 +- .../Extensions/RegistrationExtensions.cs | 26 + .../Extensions/ValidatorExtensions.cs | 35 + .../RequestValidationBehavior.cs | 44 +- .../ValidationResultModel.cs | 6 +- .../BuildingBlocks.Web.csproj | 2 +- .../BuildingBlocks.Web/CorsOptions.cs | 6 + .../Extensions/CorsExtensions.cs | 39 + .../Extensions/HeaderDictionaryExtensions.cs | 61 + .../Extensions/HostEnvironmentExtensions.cs | 10 + .../Extensions/HttpClientExtensions.cs | 42 + .../Extensions/HttpContextExtensions.cs | 48 + .../Extensions/HttpQueryExtensions.cs | 28 + .../HttpResponseMessageExtensions.cs | 23 + .../Extensions/QueryCollectionExtensions.cs | 96 + .../Extensions/VersioningExtensions.cs | 3 + .../CaptureExceptionMiddlewareExtensions.cs | 19 + .../CaptureExceptionMiddlewareImp.cs | 42 + .../RequestLogContextMiddleware.cs | 23 + .../RequestLogContextMiddlewareExtensions.cs | 11 + .../EndpointConventionBuilderExtensions.cs | 123 + .../EndpointRouteBuilderExtensions.cs | 136 + .../Extensions/MinimalApiExtensions.cs | 124 + .../Extensions/TypedResultsExtensions.cs | 61 + .../BuildingBlocks.Web/Minimal/HttpCommand.cs | 14 + .../BuildingBlocks.Web/Minimal/HttpQuery.cs | 13 + .../Modules/Extensions/ModuleExtensions.cs | 119 + .../Problem/DefaultProblemDetailMapper.cs | 31 + .../HttpResults/ConflictHttpProblemResult.cs | 52 + .../HttpResults/ForbidenHttpProblemResult.cs | 52 + .../HttpResults/InternalHttpProblemResult.cs | 54 + .../HttpResults/NotFoundHttpProblemResult.cs | 52 + .../UnAuthorizedHttpProblemResult.cs | 54 + .../Problem/ProblemDetailsService.cs | 101 + .../Problem/ProblemDetailsWriter.cs | 35 + .../Problem/RegistrationExtensions.cs | 43 + src/Directory.Build.props | 3 +- src/Directory.Packages.props | 272 +- .../FoodDelivery.Services.Billing.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Billing/Class1.cs | 3 + .../FoodDelivery.Services.Billing.csproj | 2 + .../FoodDelivery.Services.Carts.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Carts/Class1.cs | 3 + .../FoodDelivery.Services.Carts.csproj | 2 + src/Services/Carts/readme.md | 2 + src/Services/Catalogs/Directory.Build.props | 21 + src/Services/Catalogs/Dockerfile | 99 + .../CatalogsApiMetadata.cs | 3 + .../FoodDelivery.Services.Catalogs.Api.csproj | 33 + .../Program.cs | 86 + .../Properties/launchSettings.json | 49 + .../appsettings.development.json | 15 + .../appsettings.docker.json | 41 + .../appsettings.json | 76 + .../appsettings.test.json | 11 + .../Brands/Brand.cs | 28 + .../Brands/Configs.cs | 29 + .../Brands/Contracts/IBrandChecker.cs | 8 + .../Brands/Data/BrandDataSeeder.cs | 37 + .../Brands/Data/BrandEntityConfiguration.cs | 29 + .../Brands/Data/BrandFaker.cs | 19 + .../Application/BrandNotFoundException.cs | 12 + .../Domain/BrandNotFoundException.cs | 9 + .../Brands/Services/BrandChecker.cs | 25 + .../Brands/ValueObjects/BrandId.cs | 16 + .../Brands/ValueObjects/BrandName.cs | 27 + .../CatalogCacheKey.cs | 8 + .../CatalogConstants.cs | 12 + .../CatalogsMetadata.cs | 3 + .../Categories/Category.cs | 72 + .../Categories/CategoryId.cs | 16 + .../Categories/CategoryImage.cs | 22 + .../Categories/Configs.cs | 29 + .../Categories/Contracts/ICategoryChecker.cs | 6 + .../Categories/Data/CategoryDataSeeder.cs | 35 + .../Data/CategoryEntityTypeConfiguration.cs | 39 + .../Categories/Data/CategoryFaker.cs | 26 + .../Application/CategoryNotFoundException.cs | 12 + .../Domain/CategoryNotFoundException.cs | 9 + .../Categories/Services/CategoryChecker.cs | 24 + .../Categories/ValueObjects/CategoryCode.cs | 28 + .../Categories/ValueObjects/CategoryName.cs | 26 + .../FoodDelivery.Services.Catalogs.csproj | 48 + .../Products/Data/ProductDataSeeder.cs | 31 + .../Data/ProductEntityTypeConfiguration.cs | 105 + .../Products/Data/ProductFaker.cs | 55 + .../ProductImageEntityTypeConfiguration.cs | 19 + .../ProductViewEntityTypeConfiguration.cs | 17 + .../Data/SieveProductReadConfigurations.cs | 15 + .../Products/Dtos/v1/ProductDto.cs | 26 + .../Products/Dtos/v1/ProductImageDto.cs | 3 + .../Products/Dtos/v1/ProductViewDto.cs | 3 + .../Application/ProductNotFoundException.cs | 12 + .../Domain/InsufficientStockException.cs | 9 + .../MaxStockThresholdReachedException.cs | 9 + .../Domain/ProductDomainException.cs | 9 + .../v1/ChangeMaxThreshold.cs | 5 + .../v1/MaxThresholdChanged.cs | 15 + .../v1/ChangeProductBrand.cs | 15 + .../v1/Events/Domain/ProductBrandChanged.cs | 17 + .../v1/ChangeProductCategory.cs | 15 + .../v1/Events/ProductCategoryChanged.cs | 17 + .../ProductCategoryChangedNotification.cs | 7 + .../v1/ProductPriceChanged.cs | 15 + .../v1/ChangeProductSupplier.cs | 15 + .../v1/Events/ProductSupplierChanged.cs | 17 + .../v1/ChangeRestockThreshold.cs | 5 + .../v1/RestockThresholdChanged.cs | 15 + .../CreatingProduct/v1/CreateProduct.cs | 201 + .../v1/CreateProductEndpoint.cs | 96 + .../v1/Events/Domain/ProductCreated.cs | 145 + .../ProductCreatedNotification.cs | 35 + .../v1/DebitProdctStock.cs | 62 + .../v1/DebitProductStockEndpoint.cs | 53 + .../Domain/ProductRestockThresholdReached.cs | 63 + .../v1/Events/Domain/ProductStockDebited.cs | 57 + .../v1/GetAvailableStockById.cs | 5 + .../GettingProductById/v1/GetProductById.cs | 66 + .../v1/GetProductByIdEndpoint.cs | 61 + .../GettingProducts/v1/GetProducts.cs | 86 + .../GettingProducts/v1/GetProductsEndpoint.cs | 71 + .../GettingProductsView/v1/GetProductsView.cs | 76 + .../v1/GetProductsViewEndpoint.cs | 66 + .../Events/Domain/ProductStockReplenished.cs | 63 + .../v1/ReplenishProductStock.cs | 61 + .../v1/ReplenishProductStockEndpoint.cs | 59 + .../UpdatingProduct/v1/ProductUpdated.cs | 154 + .../UpdatingProduct/v1/UpdateProduct.cs | 190 + .../v1/UpdateProductEndpoint.cs | 78 + .../Products/MassTransitExtensions.cs | 40 + .../Products/Models/Product.cs | 399 + .../Products/Models/ProductColor.cs | 11 + .../Products/Models/ProductImage.cs | 26 + .../Products/Models/ProductStatus.cs | 7 + .../Products/Models/ProductType.cs | 7 + .../Products/Models/ProductView.cs | 13 + .../Models/Read/ProductImageReadModel.cs | 9 + .../Products/Models/Read/ProductReadModel.cs | 25 + .../Products/ProductEventMapper.cs | 106 + .../Products/ProductMappers.cs | 104 + .../Products/ProductsConfigs.cs | 58 + ...BrandIdShouldExistRuleWithExceptionType.cs | 31 + ...egoryIdShouldExistRuleWithExceptionType.cs | 34 + .../Products/Rules/SupplierShouldExistRule.cs | 32 + .../Products/ValueObjects/Dimensions.cs | 54 + .../Products/ValueObjects/Name.cs | 28 + .../Products/ValueObjects/Price.cs | 41 + .../Products/ValueObjects/ProductId.cs | 19 + .../ValueObjects/ProductInformation.cs | 24 + .../Products/ValueObjects/Size.cs | 32 + .../Products/ValueObjects/Stock.cs | 61 + .../ValueObjects/SupplierInformation.cs | 26 + .../Contracts/AggregateFuncOperation.cs | 5 + .../Shared/Contracts/ICatalogDbContext.cs | 21 + .../Shared/Data/CatalogDbContext.cs | 30 + .../Shared/Data/CatalogReadDbContext.cs | 18 + .../Shared/Data/CatalogsDataSeeder.cs | 13 + .../Shared/Data/CatalogsMigrationExecutor.cs | 27 + .../Shared/Data/DbContextDesignFactory.cs | 9 + ...224742_InitialCatalogMigration.Designer.cs | 627 ++ .../20240719224742_InitialCatalogMigration.cs | 291 + .../Catalogs/CatalogDbContextModelSnapshot.cs | 624 ++ .../Extensions/CatalogDbContextExtensions.cs | 88 + .../Infrastructure.cs | 129 + .../Persistence.cs | 51 + .../ProblemDetails.cs | 27 + .../Infrastructure.cs | 67 + .../WebApplicationExtensions/Migration.cs | 14 + .../Shared/SharedModulesConfiguration.cs | 51 + .../Suppliers/Configs.cs | 29 + .../Suppliers/Contracts/ISupplierChecker.cs | 6 + .../Suppliers/Data/SupplierDataSeeder.cs | 30 + .../Data/SupplierEntityTypeConfiguration.cs | 22 + .../Suppliers/Data/SupplierFaker.cs | 25 + .../Application/SupplierNotFoundException.cs | 12 + .../Domain/SupplierDomainException.cs | 9 + .../Domain/SupplierNotFoundException.cs | 9 + .../Integration/External/SupplierCreated.cs | 15 + .../Integration/External/SupplierDeleted.cs | 15 + .../Integration/External/SupplierUpdated.cs | 15 + .../Suppliers/Services/SupplierChecker.cs | 24 + .../Suppliers/Supplier.cs | 14 + .../Suppliers/SupplierId.cs | 16 + .../FoodDelivery.Services.Catalogs/readme.md | 6 + src/Services/Catalogs/dev.Dockerfile | 109 + src/Services/Catalogs/migrations.bat | 5 + src/Services/Catalogs/nuget.config | 8 + src/Services/Catalogs/watch.Dockerfile | 50 + ...FoodDelivery.Services.Checkouts.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Checkouts/Class1.cs | 3 + .../FoodDelivery.Services.Checkouts.csproj | 2 + src/Services/Checkouts/readme.md | 3 + src/Services/Customers/Directory.Build.props | 22 + src/Services/Customers/Dockerfile | 100 + .../CustomersApiMetadata.cs | 3 + ...FoodDelivery.Services.Customers.Api.csproj | 34 + .../Program.cs | 88 + .../Properties/launchSettings.json | 50 + .../appsettings.development.json | 15 + .../appsettings.docker.json | 42 + .../appsettings.json | 89 + .../appsettings.test.json | 11 + .../readme.md | 7 + .../Customers/CustomersConfigs.cs | 38 + .../Customers/CustomersEventMapper.cs | 39 + .../Customers/CustomersMapping.cs | 105 + .../Data/CustomerEntityTypeConfiguration.cs | 94 + .../Customers/Data/CustomersDataSeeder.cs | 13 + .../Mongo/CustomerReadRepository.cs | 15 + .../Data/UOW/Mongo/CustomersReadUnitOfWork.cs | 22 + .../Customers/Dtos/v1/AddressDto.cs | 3 + .../Customers/Dtos/v1/CustomerDto.cs | 3 + .../Customers/Dtos/v1/CustomerReadDto.cs | 17 + .../CustomerAlreadyExistsException.cs | 24 + .../Application/CustomerNotFoundException.cs | 15 + .../CustomerAlreadyCompletedException.cs | 14 + .../CustomerAlreadyVerifiedException.cs | 14 + .../Domain/CustomerDomainException.cs | 9 + .../Domain/CustomerNotActiveException.cs | 14 + .../Exceptions/Domain/InvalidNameException.cs | 14 + .../Domain/InvalidNationalityException.cs | 14 + .../Domain/UnsupportedNationalityException.cs | 14 + .../Customers/Extensions/GuardExtensions.cs | 3 + .../Extensions/MassTransitExtensions.cs | 22 + .../CreatingCustomer/v1/CreateCustomer.cs | 91 + .../v1/CreateCustomerEndpoint.cs | 69 + .../v1/Events/Domain/CustomerCreated.cs | 117 + .../v1/Read/Mongo/CreateCustomerRead.cs | 107 + .../v1/GetCustomerByCustomerId.cs | 61 + .../v1/GetCustomerByCustomerIdEndpoint.cs | 70 + .../GettingCustomerById/v1/GetCustomerById.cs | 59 + .../v1/GetCustomerByIdEndpoint.cs | 68 + .../GettingCustomers/v1/GetCustomers.cs | 77 + .../v1/GetCustomersEndpoint.cs | 79 + .../v1/Events/Domain/CustomerUpdated.cs | 107 + .../v1/Read/Mongo/UpdateCustomerRead.cs | 116 + .../UpdatingCustomer/v1/UpdateCustomer.cs | 120 + .../v1/UpdateCustomerEndpoint.cs | 64 + .../Customers/Models/Customer.cs | 155 + .../Customers/Models/Reads/Customer.cs | 21 + .../Customers/Models/UserState.cs | 7 + .../Customers/ValueObjects/CustomerId.cs | 17 + .../Customers/ValueObjects/CustomerName.cs | 44 + .../Customers/ValueObjects/Nationality.cs | 44 + .../CustomersConstants.cs | 10 + .../CustomersMetadata.cs | 3 + .../FoodDelivery.Services.Customers.csproj | 46 + .../Exceptions/ProductNotFoundException.cs | 9 + .../External/ProductCreatedConsumer.cs | 12 + .../ProductStockReplenishedConsumer.cs | 37 + .../Products/MassTransitExtensions.cs | 68 + .../Products/Models/Product.cs | 24 + .../Products/ProductId.cs | 16 + .../RestockSubscriptionReadRepository.cs | 15 + .../RestockSubscriptionEntityConfiguration.cs | 41 + .../Dtos/v1/RestockSubscriptionDto.cs | 13 + .../Dtos/v1/RestockSubscriptionReadDto.cs | 11 + .../RestockSubscriptionNotFoundException.cs | 12 + .../RestockSubscriptionDomainException.cs | 9 + ...reateMongoRestockSubscriptionReadModels.cs | 50 + .../v1/CreateRestockSubscription.cs | 125 + .../v1/CreateRestockSubscriptionEndpoint.cs | 71 + .../Domain/RestockSubscriptionCreated.cs | 99 + .../ProductAlreadySubscribedException.cs | 12 + .../v1/Exceptions/ProductHasStockException.cs | 11 + .../v1/DeleteRestockSubscription.cs | 66 + .../v1/DeleteRestockSubscriptionEndpoint.cs | 53 + .../v1/RestockSubscriptionDeleted.cs | 62 + ...DeleteRestockSubscriptionByTimeEndpoint.cs | 64 + .../v1/DeleteRestockSubscriptionsByTime.cs | 74 + .../v1/GetRestockSubscriptionById.cs | 63 + .../v1/GetRestockSubscriptionByIdEndpoint.cs | 70 + .../GetRestockSubscriptionBySubscriptionId.cs | 68 + ...ockSubscriptionBySubscriptionIdEndpoint.cs | 75 + .../v1/GetRestockSubscriptions.cs | 96 + .../v1/GetRestockSubscriptionsEndpoint.cs | 101 + .../Domain/RestockNotificationProcessed.cs | 61 + .../v1/ProcessRestockNotification.cs | 77 + ...UpdateMongoRestockSubscriptionReadModel.cs | 62 + ...ongoRestockSubscriptionsReadModelByTime.cs | 53 + .../v1/SendRestockNotification.cs | 71 + .../MassTransitExtensions.cs | 22 + .../Models/Read/RestockSubscription.cs | 18 + .../Models/Write/RestockSubscription.cs | 80 + .../RestockSubscriptionsConfigs.cs | 31 + .../RestockSubscriptionsEventMapper.cs | 27 + .../RestockSubscriptionsMapping.cs | 41 + .../ValueObjects/ProductInformation.cs | 23 + .../ValueObjects/RestockSubscriptionId.cs | 16 + .../Clients/Catalogs/CatalogApiClient.cs | 82 + .../Catalogs/CatalogsApiClientOptions.cs | 7 + .../Catalogs/Dtos/GetProductByIdClientDto.cs | 3 + .../Clients/Catalogs/Dtos/ProductClientDto.cs | 22 + .../Clients/Catalogs/Dtos/ProductStatus.cs | 7 + .../Clients/Catalogs/ICatalogApiClient.cs | 15 + .../Identity/Dtos/CreateUserClientDto.cs | 10 + .../Dtos/CreateUserIdentityClientDto.cs | 3 + .../Identity/Dtos/GetUserByEmailClientDto.cs | 3 + .../Identity/Dtos/IdentityUserClientDto.cs | 10 + .../Clients/Identity/IIdentityApiClient.cs | 21 + .../Clients/Identity/IdentityApiClient.cs | 114 + .../Identity/IdentityApiClientOptions.cs | 7 + .../Shared/Clients/MappingProfile.cs | 16 + .../Contracts/ICustomerReadRepository.cs | 6 + .../Shared/Contracts/ICustomersDbContext.cs | 13 + .../Contracts/ICustomersReadUnitOfWork.cs | 10 + .../IRestockSubscriptionReadRepository.cs | 6 + .../Shared/Data/CustomersDataSeeder.cs | 13 + .../Shared/Data/CustomersDbContext.cs | 26 + .../Shared/Data/CustomersMigrationExecutor.cs | 28 + .../Shared/Data/CustomersReadDbContext.cs | 20 + .../Shared/Data/DbContextDesignFactory.cs | 9 + ...4856_InitialCustomersMigration.Designer.cs | 352 + ...0240719224856_InitialCustomersMigration.cs | 129 + .../CustomersDbContextModelSnapshot.cs | 349 + .../CustomersDbContextExtensions.cs | 19 + .../HttpClient.cs | 59 + .../Infrastructure.cs | 156 + .../Persistence.cs | 56 + .../ProblemDetails.cs | 28 + .../Infrastructure.cs | 68 + .../WebApplicationExtensions/Migration.cs | 14 + .../Shared/SharedModulesConfiguration.cs | 51 + .../TestDomainEvent.cs | 5 + .../TestIntegration.cs | 5 + .../TestIntegrationConsumer.cs | 12 + .../External/UserRegisteredConsumer.cs | 25 + .../Users/MassTransitExtensions.cs | 40 + .../Users/Model/IdentityUser.cs | 10 + .../FoodDelivery.Services.Customers/readme.md | 6 + src/Services/Customers/dev.Dockerfile | 110 + src/Services/Customers/migrations.bat | 5 + src/Services/Customers/nuget.config | 8 + src/Services/Customers/watch.Dockerfile | 50 + .../Controllers/WeatherForecastController.cs | 46 + ...Delivery.Services.GroceryStores.Api.csproj | 8 + .../Program.cs | 26 + .../Properties/launchSettings.json | 41 + .../WeatherForecast.cs | 12 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Class1.cs | 3 + ...FoodDelivery.Services.GroceryStores.csproj | 2 + src/Services/Identity/Directory.Build.props | 22 + src/Services/Identity/Dockerfile | 100 + .../FoodDelivery.Services.Identity.Api.csproj | 35 + .../IdentityApiMetadata.cs | 3 + .../Middlewares/RevokeTokenMiddleware.cs | 55 + .../Program.cs | 92 + .../Properties/launchSettings.json | 48 + .../appsettings.development.json | 28 + .../appsettings.docker.json | 32 + .../appsettings.json | 79 + .../appsettings.test.json | 11 + .../tempkey.jwk | 1 + .../FoodDelivery.Services.Identity.csproj | 64 + .../AccessTokenConfiguration.cs | 24 + .../ApplicationRoleConfiguration.cs | 15 + .../ApplicationUserConfiguration.cs | 34 + .../EmailVerificationCodeConfiguration.cs | 22 + .../PasswordResetCodeConfiguration.cs | 22 + .../RefreshTokenConfiguration.cs | 25 + .../Identity/Data/IdentityDataSeeder.cs | 69 + .../Identity/Dtos/v1/RefreshTokenDto.cs | 14 + .../Exceptions/EmailNotConfirmedException.cs | 15 + .../Exceptions/InvalidTokenException.cs | 11 + .../Exceptions/PasswordIsInvalidException.cs | 9 + .../PhoneNumberNotConfirmedException.cs | 9 + .../RefreshTokenNotFoundException.cs | 10 + .../Exceptions/RequiresTwoFactorException.cs | 9 + .../Exceptions/UserLockedException.cs | 9 + .../GeneratingJwtToken/v1/GenerateJwtToken.cs | 94 + .../v1/GenerateRefreshToken.cs | 100 + .../Features/GettingClaims/v1/ClaimDto.cs | 7 + .../Features/GettingClaims/v1/GetClaims.cs | 29 + .../GettingClaims/v1/GetClaimsEndpoint.cs | 52 + .../v1/GetRefreshTokenValidity.cs | 39 + .../Identity/Features/Login/v1/Login.cs | 162 + .../Features/Login/v1/LoginEndpoint.cs | 70 + .../Features/Login/v1/LoginFailedException.cs | 14 + .../Features/Logout/v1/LogoutEndpoint.cs | 71 + .../v1/InvalidRefreshTokenException.cs | 9 + .../RefreshingToken/v1/RefreshToken.cs | 104 + .../v1/RefreshTokenEndpoint.cs | 65 + .../ResettingPassword/v1/ResetPassword.cs | 5 + .../v1/RevokeAccessToken.cs | 59 + .../v1/RevokeAccessTokenEndpoint.cs | 61 + .../v1/RevokeAllAccessTokens.cs | 54 + .../v1/RevokeAllAccessTokensEndpoint.cs | 53 + .../v1/RevokeRefreshToken.cs | 44 + .../v1/RevokeRefreshTokenEndpoint.cs | 53 + .../v1/SendEmailVerificationCode.cs | 89 + .../v1/SendEmailVerificationCodeEndpoint.cs | 54 + .../v1/SendResetPasswordCode.cs | 5 + .../EmailAlreadyVerifiedException.cs | 9 + .../VerificationTokenIsInvalidException.cs | 9 + .../Features/VerifyingEmail/v1/VerifyEmail.cs | 79 + .../VerifyingEmail/v1/VerifyEmailEndpoint.cs | 53 + .../Identity/IdentityConfigs.cs | 94 + .../Identity/IdentityMapping.cs | 14 + .../Identity/IdentityServerConfig.cs | 70 + .../Services/IdentityProfileService.cs | 51 + .../IdentityConstants.cs | 10 + .../IdentityMetadata.cs | 3 + .../Shared/Data/DbContextDesignFactory.cs | 9 + .../Shared/Data/IdentityContext.cs | 123 + .../Shared/Data/IdentityDataSeeder.cs | 13 + .../Shared/Data/IdentityMigrationExecutor.cs | 27 + ...InitialIdentityServerMigration.Designer.cs | 568 ++ ...19224959_InitialIdentityServerMigration.cs | 344 + .../Identity/IdentityContextModelSnapshot.cs | 565 ++ .../IdentityUserNotFoundException.cs | 12 + .../Extensions/UserManagerExtensions.cs | 82 + .../Identity.cs | 75 + .../IdentityServer.cs | 30 + .../Infrastructure.cs | 120 + .../ProblemDetails.cs | 27 + .../Infrastructure.cs | 71 + .../WebApplicationExtensions/Migration.cs | 14 + .../Shared/Models/AccessToken.cs | 11 + .../Shared/Models/ApplicationRole.cs | 22 + .../Shared/Models/ApplicationUser.cs | 18 + .../Shared/Models/ApplicationUserRole.cs | 9 + .../Shared/Models/EmailVerificationCode.cs | 15 + .../Shared/Models/PasswordResetCode.cs | 14 + .../Shared/Models/RefreshToken.cs | 40 + .../Shared/Models/UserState.cs | 7 + .../Shared/SharedModulesConfiguration.cs | 48 + .../Users/Dtos/v1/IdentityUserDto.cs | 18 + .../GettingUerByEmail/v1/GetUserByEmail.cs | 59 + .../v1/GetUserByEmailEndpoint.cs | 55 + .../GettingUserById/v1/GetUserById.cs | 50 + .../GettingUserById/v1/GetUserByIdEndpoint.cs | 52 + .../Features/GettingUsers/v1/GetUsers.cs | 79 + .../GettingUsers/v1/GetUsersEndpoint.cs | 65 + .../v1/RegisterIdentityUserException.cs | 9 + .../RegisteringUser/v1/RegisterUser.cs | 173 + .../v1/RegisterUserEndpoint.cs | 72 + .../v1/Events/Integration/UserStateUpdated.cs | 18 + .../UpdatingUserState/v1/UpdateUserState.cs | 90 + .../v1/UpdateUserStateEndpoint.cs | 53 + .../v1/UserStateCannotBeChangedException.cs | 21 + .../Users/MassTransitExtensions.cs | 29 + .../Users/UsersConfigs.cs | 48 + .../Users/UsersMapping.cs | 21 + .../FoodDelivery.Services.Identity/readme.md | 6 + src/Services/Identity/dev.Dockerfile | 110 + src/Services/Identity/migrations.bat | 4 + src/Services/Identity/nuget.config | 8 + src/Services/Identity/readme.md | 15 + src/Services/Identity/watch.Dockerfile | 51 + src/Services/Orders/Directory.Build.props | 22 + src/Services/Orders/Dockerfile | 100 + .../FoodDelivery.Services.Orders.Api.csproj | 34 + .../Program.cs | 86 + .../Properties/launchSettings.json | 50 + .../appsettings.development.json | 15 + .../appsettings.docker.json | 38 + .../appsettings.json | 83 + .../appsettings.test.json | 19 + .../External/CustomerCreatedConsumer.cs | 12 + .../Customers/MassTransitExtensions.cs | 40 + .../FoodDelivery.Services.Orders.csproj | 41 + .../OrderEntityTypeConfiguration.cs | 24 + .../Orders/Models/Order.cs | 22 + .../Orders/Models/Reads/OrderReadModel.cs | 7 + .../Orders/ValueObjects/CustomerInfo.cs | 25 + .../Orders/ValueObjects/OrderId.cs | 19 + .../Orders/ValueObjects/ProductInfo.cs | 29 + .../OrdersConstants.cs | 10 + .../OrdersMetadata.cs | 3 + .../Shared/Contracts/IOrdersDbContext.cs | 10 + ...9225056_InitialOrdersMigration.Designer.cs | 121 + .../20240719225056_InitialOrdersMigration.cs | 56 + .../Orders/OrdersDbContextModelSnapshot.cs | 118 + .../Shared/Data/OrderReadDbContext.cs | 18 + .../Shared/Data/OrdersDataSeeder.cs | 13 + .../Shared/Data/OrdersDbContext.cs | 24 + .../Data/OrdersDbContextDesignFactory.cs | 9 + .../Shared/Data/OrdersMigrationExecutor.cs | 27 + .../Extensions/OrdersDbContextExtensions.cs | 19 + .../Infrastructure.cs | 141 + .../Persistence.cs | 49 + .../ProblemDetails.cs | 27 + .../Infrastructure.cs | 68 + .../WebApplicationExtensions/Migration.cs | 14 + .../Shared/SharedModulesConfiguration.cs | 51 + .../FoodDelivery.Services.Orders/readme.md | 6 + src/Services/Orders/dev.Dockerfile | 110 + src/Services/Orders/nuget.config | 8 + src/Services/Orders/watch.Dockerfile | 49 + .../FoodDelivery.Services.Pricing.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Pricing/Class1.cs | 3 + .../FoodDelivery.Services.Pricing.csproj | 2 + src/Services/Pricing/readme.md | 2 + ...livery.Services.Recommendations.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Class1.cs | 3 + ...odDelivery.Services.Recommendations.csproj | 2 + .../Controllers/WeatherForecastController.cs | 46 + ...odDelivery.Services.Restaurants.Api.csproj | 8 + .../Program.cs | 26 + .../Properties/launchSettings.json | 41 + .../WeatherForecast.cs | 12 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Class1.cs | 3 + .../FoodDelivery.Services.Restaurants.csproj | 2 + .../FoodDelivery.Services.Reviews.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Reviews/Class1.cs | 3 + .../FoodDelivery.Services.Reviews.csproj | 2 + .../FoodDelivery.Services.Search.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Search/Class1.cs | 3 + .../FoodDelivery.Services.Search.csproj | 2 + .../Events/v1/Integration/ProductCreatedV1.cs | 27 + .../v1/Integration/ProductStockDebitedV1.cs | 23 + .../Integration/ProductStockReplenishedV1.cs | 23 + .../Events/v1/Integration/ProductUpdatedV1.cs | 27 + .../v1/Integration/SupplierCreatedV1.cs | 5 + .../v1/Integration/SupplierDeletedV1.cs | 5 + .../v1/Integration/SupplierUpdatedV1.cs | 5 + .../v1/Integration/CustomerCreatedV1.cs | 14 + .../v1/Integration/CustomerUpdatedV1.cs | 66 + .../RestockSubscriptionCreatedV1.cs | 5 + .../FoodDelivery.Services.Shared.csproj | 19 + .../Events/v1/Integration/UserRegisteredV1.cs | 47 + ...FoodDelivery.Services.Shippings.Api.csproj | 8 + .../Program.cs | 59 + .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../FoodDelivery.Services.Shippings/Class1.cs | 3 + .../FoodDelivery.Services.Shippings.csproj | 2 + .../AggregateFactoryTests.cs | 24 + .../AggregateTests.cs | 138 + .../BuildingBlocks.Core.UnitTests.csproj | 30 + .../EventSourcedAggregateTests.cs | 203 + .../StreamNameTests.cs | 20 + .../TypeMapperTests.cs | 27 + .../BuildingBlocks.Core.UnitTests/Usings.cs | 1 + .../xunit.runner.json | 4 + ...tence.EventStoreDB.IntegrationTests.csproj | 34 + .../DependencyInjectionsTests.cs | 40 + .../EventStoreDbEventStoreTests.cs | 263 + .../Fixtures/IntegrationFixture.cs | 43 + .../appsettings.json | 11 + .../xunit.runner.json | 4 + ...Persistence.Marten.IntegrationTests.csproj | 34 + .../appsettings.json | 11 + .../xunit.runner.json | 4 + ...ration.MassTransit.IntegrationTests.csproj | 6 - tests/Directory.Build.props | 2 +- tests/Directory.Packages.props | 55 +- .../DependencyTests.cs | 31 + ...y.Services.Catalogs.DependencyTests.csproj | 22 + .../Usings.cs | 1 + ...ery.Services.Catalogs.EndToEndTests.csproj | 29 + .../UnitTest1.cs | 7 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + ....Services.Catalogs.IntegrationTests.csproj | 30 + .../UnitTest1.cs | 61 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + ...elivery.Services.Catalogs.LoadTests.csproj | 22 + .../UnitTest1.cs | 7 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + .../Fakes/Banners/Tests.cs | 17 + .../Fakes/Categories/Tests.cs | 17 + .../Fakes/Products/Tests.cs | 17 + .../Fakes/Suppliers/Tests.cs | 17 + ...livery.Services.Catalogs.TestShared.csproj | 17 + .../Common/CatalogsServiceUnitTestBase.cs | 30 + .../Common/DbContextFactory.cs | 31 + .../Common/MapperFactory.cs | 17 + .../Common/MappingFixture.cs | 13 + ...elivery.Services.Catalogs.UnitTests.csproj | 22 + .../Products/ProductsMappingTests.cs | 35 + .../UnitTestCollection.cs | 9 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + .../DependencyTests.cs | 31 + ....Services.Customers.DependencyTests.csproj | 22 + .../Usings.cs | 1 + .../Constants.cs | 40 + .../CustomerServiceEndToEndTestBase.cs | 69 + .../v1/CreateCustomerTests.cs | 124 + .../v1/GetCustomerByIdTests.cs | 162 + .../EndToEndTestCollection.cs | 17 + ...ry.Services.Customers.EndToEndTests.csproj | 29 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + .../.vscode/settings.json | 3 + .../CustomerServiceIntegrationTestBase.cs | 71 + .../v1/CreateCustomerTests.cs | 172 + .../v1/GetCustomerByCustomerIdTests.cs | 51 + .../v1/GetCustomerByIdTests.cs | 50 + .../GettingCustomers/v1/GetCustomersTests.cs | 63 + ...Services.Customers.IntegrationTests.csproj | 29 + .../IntegrationTestCollection.cs | 17 + .../IntegrationTestConfigurations.cs | 12 + .../v1/CreateRestockSubscriptionTests.cs | 56 + .../v1/Events/External/UserRegisteredTests.cs | 143 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + ...livery.Services.Customers.LoadTests.csproj | 22 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + .../Customers/Commands/FakeCreateCustomer.cs | 19 + .../Commands/FakeCreateCustomerRead.cs | 13 + .../Customers/Commands/FakeUpdateCustomer.cs | 24 + .../Commands/FakeUpdateCustomerRead.cs | 13 + .../Fakes/Customers/Entities/FakeCustomer.cs | 77 + .../Entities/FakeCustomerReadModel.cs | 22 + .../Events/AutoFakeUserRegisteredV1.cs | 13 + .../Customers/Events/FakeUserRegisteredV1.cs | 24 + .../Requests/FakeCreateCustomerRequest.cs | 12 + .../Fakes/Customers/Tests.cs | 131 + .../Entities/FakeRestockSubscriptions.cs | 28 + .../Fakes/RestockSubscriptions/Tests.cs | 37 + .../ValueObjects/FakeProductInformation.cs | 13 + .../Fakes/Shared/Dtos/FakeProductDto.cs | 32 + .../Fakes/Shared/Dtos/FakeUserIdentityDto.cs | 18 + .../Shared/Servers/CatalogServiceMockTests.cs | 48 + .../Shared/Servers/CatalogsServiceMock.cs | 61 + .../Shared/Servers/IdentityServiceMock.cs | 86 + .../Servers/IdentityServiceMockTests.cs | 65 + .../Fakes/Shared/Tests.cs | 26 + .../CustomersServiceMockServersFixture.cs | 28 + ...ivery.Services.Customers.TestShared.csproj | 21 + .../Common/CustomerServiceUnitTestBase.cs | 71 + .../Common/DbContextFactory.cs | 31 + .../Common/MapperFactory.cs | 19 + .../Common/MappingFixture.cs | 13 + .../Customers/CustomersMappingTests.cs | 81 + .../v1/CreateCustomerTests.cs | 134 + .../v1/CreateCustomerValidatorTests.cs | 60 + .../v1/Read/CreateCustomerTests.cs | 49 + .../GetCustomerByCustomerIdTests.cs | 92 + .../v1/GetCustomerByIdTests.cs | 92 + .../v1/Read/UpdateCustomerTests.cs | 93 + .../v1/UpdateCustomerTests.cs | 81 + .../v1/UpdateCustomerValidatorTests.cs | 193 + .../Customers/Models/CustomerTests.cs | 118 + .../Customers/ValueObjects/CustomerIdTests.cs | 42 + .../ValueObjects/CustomerNameTests.cs | 48 + .../ValueObjects/NationalityTests.cs | 3 + ...livery.Services.Customers.UnitTests.csproj | 22 + .../v1/CreateRestockSubscriptionTests.cs | 209 + .../RestockSubscriptionMappingTests.cs | 84 + .../UnitTestCollection.cs | 12 + .../XunitMetadata.cs | 6 + .../appsettings.json | 10 + .../xunit.runner.json | 8 + ...ery.Services.Identity.EndToEndTests.csproj | 30 + .../UnitTest1.cs | 9 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + ....Services.Identity.IntegrationTests.csproj | 40 + .../RegisteringUser/v1/RegisterUserTests.cs | 64 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + ...livery.Services.Identity.TestShared.csproj | 17 + .../UnitTest1.cs | 7 + ...elivery.Services.Identity.UnitTests.csproj | 22 + .../Tests.cs | 10 + .../XunitMetadata.cs | 6 + .../xunit.runner.json | 8 + .../Extensions/HttpClientExtensions.cs | 19 + .../HttpResponseMessageExtensions.cs | 116 + .../Factory/CustomWebApplicationFactory.cs | 190 +- .../Fixtures/EfDbContextTransactionFixture.cs | 41 +- .../Fixtures/MongoContainerFixture.cs | 60 +- .../Fixtures/PostgresContainerFixture.cs | 66 +- .../Fixtures/RabbitMQContainerFixture.cs | 63 +- .../Tests.Shared/Fixtures/SharedFixture.cs | 161 +- .../SharedFixtureWithEfCoreAndMongo.cs | 4 +- .../Tests/EfDbContextTransactionFixture.cs | 2 +- .../Tests/MongoContainerFixtureTests.cs | 12 +- .../Tests/PostgresContainerFixtureTests.cs | 2 +- .../Tests/RabbitMQContainerFixtureTests.cs | 2 +- .../Helpers/ConfigurationHelper.cs | 3 +- .../Tests.Shared/TestBase/EndToEndTestBase.cs | 111 +- .../TestBase/IntegrationTestBase.cs | 37 +- tests/Shared/Tests.Shared/Tests.Shared.csproj | 5 + 976 files changed, 47092 insertions(+), 4274 deletions(-) create mode 100644 .bash_aliases create mode 100644 .devcontainer/docker-compose.yaml create mode 100644 .devcontainer/scripts/post-create.sh create mode 100644 .devcontainer/scripts/setup-fonts.sh create mode 100644 .devcontainer/scripts/update.sh create mode 100644 .github/workflows/catalogs.yml create mode 100644 .github/workflows/customers.yml create mode 100644 .github/workflows/gateway.yml create mode 100644 .github/workflows/identity.yml create mode 100644 .gitpod.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 _httpclients/catalogs/catalogs.rest create mode 100644 _httpclients/customers/customers.rest create mode 100644 _httpclients/http-client.env.json create mode 100644 _httpclients/identity/identity.rest create mode 100644 assets/catalog-service.png create mode 100644 assets/header.png create mode 100644 assets/level-structure.png create mode 100644 deployments/docker-compose/.env.catalogs.sample create mode 100644 deployments/docker-compose/.env.cutomers.sample create mode 100644 deployments/docker-compose/.env.dev.sample create mode 100644 deployments/docker-compose/.env.identity.sample create mode 100644 deployments/docker-compose/.env.prod create mode 100644 deployments/docker-compose/.env.prod.sample create mode 100644 deployments/docker-compose/.env.sample create mode 100644 deployments/docker-compose/docker-compose.override.yaml create mode 100644 deployments/docker-compose/docker-compose.services-win.yaml create mode 100644 deployments/docker-compose/docker-compose.services.debug-win.yaml create mode 100644 deployments/docker-compose/docker-compose.services.debug.yaml create mode 100644 deployments/docker-compose/docker-compose.services.dev-win.yaml create mode 100644 deployments/docker-compose/docker-compose.services.dev.yaml create mode 100644 deployments/docker-compose/docker-compose.services.prod.yaml create mode 100644 deployments/docker-compose/docker-compose.services.yaml create mode 100644 deployments/docker-compose/docker-compose.yaml create mode 100644 deployments/docker-compose/monitoring/prometheus.yml create mode 100644 deployments/tye/tye.yaml create mode 100644 food-delivery-microservices.sln create mode 100644 nuget.config create mode 100644 pm2.json create mode 100644 pm2.yaml create mode 100644 src/ApiGateway/Directory.Build.props create mode 100644 src/ApiGateway/Dockerfile create mode 100644 src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj create mode 100644 src/ApiGateway/FoodDelivery.ApiGateway/Program.cs create mode 100644 src/ApiGateway/FoodDelivery.ApiGateway/Properties/launchSettings.json create mode 100644 src/ApiGateway/FoodDelivery.ApiGateway/appsettings.docker.json create mode 100644 src/ApiGateway/FoodDelivery.ApiGateway/appsettings.json create mode 100644 src/ApiGateway/dev.Dockerfile create mode 100644 src/ApiGateway/watch.Dockerfile create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Queries/IPageQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/ICacheQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageList.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageRequest.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IAggregatesDomainEventsRequestStore.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IDomainEventsAccessor.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventHandler.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventMapper.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IHaveDomainEvents.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IInternalEventBus.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventContext.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventHandler.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventPublisher.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventHandler.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventPublisher.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IHaveNotificationEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IBusinessRuleWithExceptionType.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IDbExecutors.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationExecutor.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationManager.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpCommand.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Abstractions/Web/Problem/IProblemDetailMapper.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Caching/CacheQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Caching/DatabaseExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Caching/RedisOptions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/CQRS/Queries/PageQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/AggregateDomainEventsStore.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventAccessor.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventPublisher.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainNotificationEventPublisher.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Event.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventHandlerDecorator.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventsExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEventsInvoker.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEventWrapper.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/IntegrationEventWrapper.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/NotificationEvent.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/InternalEventBus.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidAmountException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidCurrencyException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidDateException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidEmailException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidPhoneNumberException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ValidationException.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/HostApplicationLifetimeExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/QueryableExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Dependency.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Options.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/IdsGenerator/SnowFlakIdGenerator.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Messaging/BackgroundServices/MessagePersistenceWorker.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Paging/ApplicationSieveProcessor.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Paging/PageList.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Paging/PageRequest.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepository.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/EventStoreDomainEventAccessor.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Persistence/MigrationManager.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Persistence/SeedWorker.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Registrations/CQRSRegistrationRegistrationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Registrations/DbExecutorRegistrationsExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Registrations/EventStoreRegistrationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Core/Threading/NoSynchronizationContextScope.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.csproj create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbEventStore.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbOptions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/EventStoreClientExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/RegistrationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/SerializationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamEventExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamRevisionExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/EventStoreDbSubscriptionCheckPointRepository.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/ISubscriptionCheckpointRepository.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/InMemorySubscriptionCheckpointRepository.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/SubscribeToAllBackgroundWorker.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/readme.md create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/BuildingBlocks.Persistence.Marten.csproj create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenEventStore.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenIdGenerator.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenOptions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/IMartenEventsConsumer.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenEventPublisher.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenSubscription.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Marten/readme.md create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoRepositoryBase.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Validation/Extensions/RegistrationExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Validation/Extensions/ValidatorExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/CorsOptions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/CorsExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HeaderDictionaryExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HostEnvironmentExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpClientExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpContextExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpQueryExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpResponseMessageExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Extensions/QueryCollectionExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareImp.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddleware.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddlewareExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointConventionBuilderExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointRouteBuilderExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/MinimalApiExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/TypedResultsExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpCommand.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpQuery.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Modules/Extensions/ModuleExtensions.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/DefaultProblemDetailMapper.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ConflictHttpProblemResult.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ForbidenHttpProblemResult.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/InternalHttpProblemResult.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/NotFoundHttpProblemResult.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/UnAuthorizedHttpProblemResult.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsService.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsWriter.cs create mode 100644 src/BuildingBlocks/BuildingBlocks.Web/Problem/RegistrationExtensions.cs create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing.Api/FoodDelivery.Services.Billing.Api.csproj create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing.Api/Program.cs create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing.Api/Properties/launchSettings.json create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.Development.json create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.json create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing/Class1.cs create mode 100644 src/Services/Billing/FoodDelivery.Services.Billing/FoodDelivery.Services.Billing.csproj create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts.Api/FoodDelivery.Services.Carts.Api.csproj create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts.Api/Program.cs create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts.Api/Properties/launchSettings.json create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.Development.json create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.json create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts/Class1.cs create mode 100644 src/Services/Carts/FoodDelivery.Services.Carts/FoodDelivery.Services.Carts.csproj create mode 100644 src/Services/Carts/readme.md create mode 100644 src/Services/Catalogs/Directory.Build.props create mode 100644 src/Services/Catalogs/Dockerfile create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/CatalogsApiMetadata.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Program.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Properties/launchSettings.json create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.development.json create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.json create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.test.json create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Brand.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Configs.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Contracts/IBrandChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandDataSeeder.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandEntityConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandFaker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Application/BrandNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Domain/BrandNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Services/BrandChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandId.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandName.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogCacheKey.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogConstants.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogsMetadata.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Category.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryId.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryImage.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Configs.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Contracts/ICategoryChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryDataSeeder.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryEntityTypeConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryFaker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Application/CategoryNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Domain/CategoryNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Services/CategoryChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryCode.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryName.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductDataSeeder.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductEntityTypeConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductFaker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductImageEntityTypeConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductViewEntityTypeConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/SieveProductReadConfigurations.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductDto.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductImageDto.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductViewDto.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Application/ProductNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/InsufficientStockException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/MaxStockThresholdReachedException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/ProductDomainException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/ChangeMaxThreshold.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/MaxThresholdChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/ChangeProductBrand.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/Events/Domain/ProductBrandChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/ChangeProductCategory.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChangedNotification.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductPrice/v1/ProductPriceChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/ChangeProductSupplier.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/Events/ProductSupplierChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/ChangeRestockThreshold.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/RestockThresholdChanged.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProduct.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Domain/ProductCreated.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Notification/ProductCreatedNotification.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProdctStock.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProductStockEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductRestockThresholdReached.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductStockDebited.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingAvailableStockById/v1/GetAvailableStockById.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductById.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductByIdEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProducts.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProductsEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsView.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsViewEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/Events/Domain/ProductStockReplenished.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStock.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStockEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/ProductUpdated.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProduct.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProductEndpoint.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/MassTransitExtensions.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Product.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductColor.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductImage.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductStatus.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductType.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductView.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductImageReadModel.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductReadModel.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductEventMapper.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductMappers.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductsConfigs.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/BrandIdShouldExistRuleWithExceptionType.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/CategoryIdShouldExistRuleWithExceptionType.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/SupplierShouldExistRule.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Dimensions.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Name.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Price.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductId.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductInformation.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Size.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Stock.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/SupplierInformation.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/AggregateFuncOperation.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/ICatalogDbContext.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogDbContext.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogReadDbContext.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsDataSeeder.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsMigrationExecutor.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/DbContextDesignFactory.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.Designer.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/CatalogDbContextModelSnapshot.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/CatalogDbContextExtensions.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Migration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/SharedModulesConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Configs.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Contracts/ISupplierChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierDataSeeder.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierEntityTypeConfiguration.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierFaker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Application/SupplierNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierDomainException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierNotFoundException.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierCreated/Events/Integration/External/SupplierCreated.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierDeleted/Events/Integration/External/SupplierDeleted.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierUpdated/Events/Integration/External/SupplierUpdated.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Services/SupplierChecker.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Supplier.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/SupplierId.cs create mode 100644 src/Services/Catalogs/FoodDelivery.Services.Catalogs/readme.md create mode 100644 src/Services/Catalogs/dev.Dockerfile create mode 100644 src/Services/Catalogs/migrations.bat create mode 100644 src/Services/Catalogs/nuget.config create mode 100644 src/Services/Catalogs/watch.Dockerfile create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/FoodDelivery.Services.Checkouts.Api.csproj create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Program.cs create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Properties/launchSettings.json create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.Development.json create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.json create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts/Class1.cs create mode 100644 src/Services/Checkouts/FoodDelivery.Services.Checkouts/FoodDelivery.Services.Checkouts.csproj create mode 100644 src/Services/Checkouts/readme.md create mode 100644 src/Services/Customers/Directory.Build.props create mode 100644 src/Services/Customers/Dockerfile create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/CustomersApiMetadata.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/Properties/launchSettings.json create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.development.json create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.json create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.test.json create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers.Api/readme.md create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersConfigs.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersEventMapper.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomerEntityTypeConfiguration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomersDataSeeder.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/Repositories/Mongo/CustomerReadRepository.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/UOW/Mongo/CustomersReadUnitOfWork.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/AddressDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerReadDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerAlreadyExistsException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerNotFoundException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyCompletedException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyVerifiedException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerDomainException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerNotActiveException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNameException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNationalityException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/UnsupportedNationalityException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomerEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerId.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerById.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerByIdEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomers.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomersEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Events/Domain/CustomerUpdated.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Read/Mongo/UpdateCustomerRead.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomerEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Reads/Customer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/UserState.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerName.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/CustomersConstants.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/Exceptions/ProductNotFoundException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/Models/Product.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Products/ProductId.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/Repositories/Mongo/RestockSubscriptionReadRepository.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/RestockSubscriptionEntityConfiguration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionReadDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Application/RestockSubscriptionNotFoundException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Domain/RestockSubscriptionDomainException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateMongoRestockSubscriptionReadModels.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscription.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductAlreadySubscribedException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductHasStockException.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscription.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscriptionEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/RestockSubscriptionDeleted.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionByTimeEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionsByTime.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionById.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionByIdEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionId.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionIdEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptionsEndpoint.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/Events/Domain/RestockNotificationProcessed.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/ProcessRestockNotification.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionReadModel.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionsReadModelByTime.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/SendingRestockNotification/v1/SendRestockNotification.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Read/RestockSubscription.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsConfigs.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsEventMapper.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsMapping.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/ProductInformation.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/RestockSubscriptionId.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogApiClient.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogsApiClientOptions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/GetProductByIdClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductStatus.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/ICatalogApiClient.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserIdentityClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/GetUserByEmailClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/IdentityUserClientDto.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IIdentityApiClient.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClient.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClientOptions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomerReadRepository.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersDbContext.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersReadUnitOfWork.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/IRestockSubscriptionReadRepository.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDataSeeder.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDbContext.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersMigrationExecutor.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersReadDbContext.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/DbContextDesignFactory.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.Designer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/CustomersDbContextModelSnapshot.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/TestDomainEvent.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/TestIntegration.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/TestIntegrationConsumer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/Users/Model/IdentityUser.cs create mode 100644 src/Services/Customers/FoodDelivery.Services.Customers/readme.md create mode 100644 src/Services/Customers/dev.Dockerfile create mode 100644 src/Services/Customers/migrations.bat create mode 100644 src/Services/Customers/nuget.config create mode 100644 src/Services/Customers/watch.Dockerfile create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Controllers/WeatherForecastController.cs create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/FoodDelivery.Services.GroceryStores.Api.csproj create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Program.cs create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Properties/launchSettings.json create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/WeatherForecast.cs create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.Development.json create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.json create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/Class1.cs create mode 100644 src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/FoodDelivery.Services.GroceryStores.csproj create mode 100644 src/Services/Identity/Directory.Build.props create mode 100644 src/Services/Identity/Dockerfile create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/IdentityApiMetadata.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/Middlewares/RevokeTokenMiddleware.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/Program.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/Properties/launchSettings.json create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.development.json create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.json create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.test.json create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity.Api/tempkey.jwk create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/AccessTokenConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationRoleConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationUserConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/EmailVerificationCodeConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/PasswordResetCodeConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/RefreshTokenConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/IdentityDataSeeder.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Dtos/v1/RefreshTokenDto.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/EmailNotConfirmedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/InvalidTokenException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PasswordIsInvalidException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PhoneNumberNotConfirmedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RefreshTokenNotFoundException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RequiresTwoFactorException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/UserLockedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingJwtToken/v1/GenerateJwtToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingRefreshToken/v1/GenerateRefreshToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/ClaimDto.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaims.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaimsEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingRefreshTokenValidity/v1/GetRefreshTokenValidity.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/Login.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginFailedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Logout/v1/LogoutEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/InvalidRefreshTokenException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshTokenEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/ResettingPassword/v1/ResetPassword.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessTokenEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokens.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokensEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshTokenEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCode.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCodeEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingResetPasswordCode/v1/SendResetPasswordCode.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/EmailAlreadyVerifiedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/VerificationTokenIsInvalidException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmail.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmailEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityConfigs.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityMapping.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityServerConfig.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Identity/Services/IdentityProfileService.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/IdentityConstants.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/IdentityMetadata.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/DbContextDesignFactory.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityContext.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityDataSeeder.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityMigrationExecutor.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.Designer.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/IdentityContextModelSnapshot.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Exceptions/IdentityUserNotFoundException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/UserManagerExtensions.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Identity.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/IdentityServer.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Migration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/AccessToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationRole.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUser.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUserRole.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/EmailVerificationCode.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/PasswordResetCode.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/RefreshToken.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/UserState.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Shared/SharedModulesConfiguration.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Dtos/v1/IdentityUserDto.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmail.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmailEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserById.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserByIdEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsers.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsersEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterIdentityUserException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUserEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/Events/Integration/UserStateUpdated.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserState.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserStateEndpoint.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UserStateCannotBeChangedException.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/MassTransitExtensions.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersConfigs.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersMapping.cs create mode 100644 src/Services/Identity/FoodDelivery.Services.Identity/readme.md create mode 100644 src/Services/Identity/dev.Dockerfile create mode 100644 src/Services/Identity/migrations.bat create mode 100644 src/Services/Identity/nuget.config create mode 100644 src/Services/Identity/readme.md create mode 100644 src/Services/Identity/watch.Dockerfile create mode 100644 src/Services/Orders/Directory.Build.props create mode 100644 src/Services/Orders/Dockerfile create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/Program.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/Properties/launchSettings.json create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.development.json create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.docker.json create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.json create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.test.json create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/Data/EntityConfigurations/OrderEntityTypeConfiguration.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Order.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Reads/OrderReadModel.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/CustomerInfo.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/OrderId.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/ProductInfo.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/OrdersConstants.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/OrdersMetadata.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Contracts/IOrdersDbContext.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.Designer.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/OrdersDbContextModelSnapshot.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrderReadDbContext.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDataSeeder.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContext.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContextDesignFactory.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersMigrationExecutor.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/OrdersDbContextExtensions.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Migration.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/Shared/SharedModulesConfiguration.cs create mode 100644 src/Services/Orders/FoodDelivery.Services.Orders/readme.md create mode 100644 src/Services/Orders/dev.Dockerfile create mode 100644 src/Services/Orders/nuget.config create mode 100644 src/Services/Orders/watch.Dockerfile create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing.Api/FoodDelivery.Services.Pricing.Api.csproj create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Program.cs create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Properties/launchSettings.json create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.Development.json create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.json create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing/Class1.cs create mode 100644 src/Services/Pricing/FoodDelivery.Services.Pricing/FoodDelivery.Services.Pricing.csproj create mode 100644 src/Services/Pricing/readme.md create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/FoodDelivery.Services.Recommendations.Api.csproj create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Program.cs create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Properties/launchSettings.json create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.Development.json create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.json create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations/Class1.cs create mode 100644 src/Services/Recommendations/FoodDelivery.Services.Recommendations/FoodDelivery.Services.Recommendations.csproj create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Controllers/WeatherForecastController.cs create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/FoodDelivery.Services.Restaurants.Api.csproj create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Program.cs create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Properties/launchSettings.json create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/WeatherForecast.cs create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.Development.json create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.json create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants/Class1.cs create mode 100644 src/Services/Restaurants/FoodDelivery.Services.Restaurants/FoodDelivery.Services.Restaurants.csproj create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews.Api/FoodDelivery.Services.Reviews.Api.csproj create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Program.cs create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Properties/launchSettings.json create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.Development.json create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.json create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews/Class1.cs create mode 100644 src/Services/Reviews/FoodDelivery.Services.Reviews/FoodDelivery.Services.Reviews.csproj create mode 100644 src/Services/Search/FoodDelivery.Services.Search.Api/FoodDelivery.Services.Search.Api.csproj create mode 100644 src/Services/Search/FoodDelivery.Services.Search.Api/Program.cs create mode 100644 src/Services/Search/FoodDelivery.Services.Search.Api/Properties/launchSettings.json create mode 100644 src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.Development.json create mode 100644 src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.json create mode 100644 src/Services/Search/FoodDelivery.Services.Search/Class1.cs create mode 100644 src/Services/Search/FoodDelivery.Services.Search/FoodDelivery.Services.Search.csproj create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductCreatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockDebitedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockReplenishedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductUpdatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierCreatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierDeletedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierUpdatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerCreatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerUpdatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Customers/RestockSubscriptions/Events/v1/Integration/RestockSubscriptionCreatedV1.cs create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj create mode 100644 src/Services/Shared/FoodDelivery.Services.Shared/Identity/Users/Events/v1/Integration/UserRegisteredV1.cs create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings.Api/FoodDelivery.Services.Shippings.Api.csproj create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Program.cs create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Properties/launchSettings.json create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.Development.json create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.json create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings/Class1.cs create mode 100644 src/Services/Shippings/FoodDelivery.Services.Shippings/FoodDelivery.Services.Shippings.csproj create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateFactoryTests.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateTests.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/BuildingBlocks.Core.UnitTests.csproj create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/EventSourcedAggregateTests.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/StreamNameTests.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/TypeMapperTests.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/Usings.cs create mode 100644 tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/xunit.runner.json create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.csproj create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/DependencyInjectionsTests.cs create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/EventStoreDbEventStoreTests.cs create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/Fixtures/IntegrationFixture.cs create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/appsettings.json create mode 100644 tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/xunit.runner.json create mode 100644 tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/BuildingBlocks.Persistence.Marten.IntegrationTests.csproj create mode 100644 tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/appsettings.json create mode 100644 tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/xunit.runner.json create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/DependencyTests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/FoodDelivery.Services.Catalogs.DependencyTests.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/Usings.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/FoodDelivery.Services.Catalogs.EndToEndTests.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/UnitTest1.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/XunitMetadata.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/xunit.runner.json create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/FoodDelivery.Services.Catalogs.IntegrationTests.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/UnitTest1.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/XunitMetadata.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/xunit.runner.json create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/FoodDelivery.Services.Catalogs.LoadTests.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/UnitTest1.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/XunitMetadata.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/xunit.runner.json create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Banners/Tests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Categories/Tests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Products/Tests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Suppliers/Tests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/FoodDelivery.Services.Catalogs.TestShared.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/CatalogsServiceUnitTestBase.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/DbContextFactory.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MapperFactory.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MappingFixture.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/FoodDelivery.Services.Catalogs.UnitTests.csproj create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Products/ProductsMappingTests.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/UnitTestCollection.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/XunitMetadata.cs create mode 100644 tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/xunit.runner.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/DependencyTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/FoodDelivery.Services.Customers.DependencyTests.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/Usings.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Constants.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/FoodDelivery.Services.Customers.EndToEndTests.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/XunitMetadata.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/xunit.runner.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/.vscode/settings.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomers/v1/GetCustomersTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/FoodDelivery.Services.Customers.IntegrationTests.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestConfigurations.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/XunitMetadata.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/xunit.runner.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/FoodDelivery.Services.Customers.LoadTests.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/XunitMetadata.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/xunit.runner.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomer.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomerRead.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomer.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomerRead.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomer.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomerReadModel.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/AutoFakeUserRegisteredV1.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/FakeUserRegisteredV1.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Requests/FakeCreateCustomerRequest.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Tests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Entities/FakeRestockSubscriptions.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Tests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/ValueObjects/FakeProductInformation.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeProductDto.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeUserIdentityDto.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Tests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/FoodDelivery.Services.Customers.TestShared.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/DbContextFactory.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MapperFactory.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MappingFixture.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/CustomersMappingTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerValidatorTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerByCustomerId/GetCustomerByCustomerIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/Read/UpdateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerValidatorTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Models/CustomerTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerIdTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerNameTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/NationalityTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/FoodDelivery.Services.Customers.UnitTests.csproj create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/RestockSubscriptionMappingTests.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/XunitMetadata.cs create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/appsettings.json create mode 100644 tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/xunit.runner.json create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/FoodDelivery.Services.Identity.EndToEndTests.csproj create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/UnitTest1.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/XunitMetadata.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/xunit.runner.json create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/FoodDelivery.Services.Identity.IntegrationTests.csproj create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/Users/Features/RegisteringUser/v1/RegisterUserTests.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/XunitMetadata.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/xunit.runner.json create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/FoodDelivery.Services.Identity.TestShared.csproj create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/UnitTest1.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/FoodDelivery.Services.Identity.UnitTests.csproj create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/Tests.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/XunitMetadata.cs create mode 100644 tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/xunit.runner.json create mode 100644 tests/Shared/Tests.Shared/Extensions/HttpResponseMessageExtensions.cs diff --git a/.bash_aliases b/.bash_aliases new file mode 100644 index 00000000..97172c31 --- /dev/null +++ b/.bash_aliases @@ -0,0 +1,162 @@ +# This command is used a LOT both below and in daily life +alias k=kubectl + +# Execute a kubectl command against all namespaces +alias kca='_kca(){ kubectl "$@" --all-namespaces; unset -f _kca; }; _kca' + +# Apply a YML file +alias kaf='kubectl apply -f' + +# Drop into an interactive terminal on a container +alias keti='kubectl exec -t -i' + +# Manage configuration quickly to switch contexts between local, dev ad staging. +alias kcuc='kubectl config use-context' +alias kcsc='kubectl config set-context' +alias kcdc='kubectl config delete-context' +alias kccc='kubectl config current-context' + +# List all contexts +alias kcgc='kubectl config get-contexts' + +# General aliases +alias kdel='kubectl delete' +alias kdelf='kubectl delete -f' + +# Pod management. +alias kgp='kubectl get pods' +alias kgpa='kubectl get pods --all-namespaces' +alias kgpw='kgp --watch' +alias kgpwide='kgp -o wide' +alias kep='kubectl edit pods' +alias kdp='kubectl describe pods' +alias kdelp='kubectl delete pods' +alias kgpall='kubectl get pods --all-namespaces -o wide' + +# get pod by label: kgpl "app=myapp" -n myns +alias kgpl='kgp -l' + +# get pod by namespace: kgpn kube-system" +alias kgpn='kgp -n' + +# Service management. +alias kgs='kubectl get svc' +alias kgsa='kubectl get svc --all-namespaces' +alias kgsw='kgs --watch' +alias kgswide='kgs -o wide' +alias kes='kubectl edit svc' +alias kds='kubectl describe svc' +alias kdels='kubectl delete svc' + +# Ingress management +alias kgi='kubectl get ingress' +alias kgia='kubectl get ingress --all-namespaces' +alias kei='kubectl edit ingress' +alias kdi='kubectl describe ingress' +alias kdeli='kubectl delete ingress' + +# Namespace management +alias kgns='kubectl get namespaces' +alias kens='kubectl edit namespace' +alias kdns='kubectl describe namespace' +alias kdelns='kubectl delete namespace' +alias kcn='kubectl config set-context --current --namespace' + +# ConfigMap management +alias kgcm='kubectl get configmaps' +alias kgcma='kubectl get configmaps --all-namespaces' +alias kecm='kubectl edit configmap' +alias kdcm='kubectl describe configmap' +alias kdelcm='kubectl delete configmap' + +# Secret management +alias kgsec='kubectl get secret' +alias kgseca='kubectl get secret --all-namespaces' +alias kdsec='kubectl describe secret' +alias kdelsec='kubectl delete secret' + +# Deployment management. +alias kgd='kubectl get deployment' +alias kgda='kubectl get deployment --all-namespaces' +alias kgdw='kgd --watch' +alias kgdwide='kgd -o wide' +alias ked='kubectl edit deployment' +alias kdd='kubectl describe deployment' +alias kdeld='kubectl delete deployment' +alias ksd='kubectl scale deployment' +alias krsd='kubectl rollout status deployment' + +# Rollout management. +alias kgrs='kubectl get replicaset' +alias kdrs='kubectl describe replicaset' +alias kers='kubectl edit replicaset' +alias krh='kubectl rollout history' +alias kru='kubectl rollout undo' + +# Statefulset management. +alias kgss='kubectl get statefulset' +alias kgssa='kubectl get statefulset --all-namespaces' +alias kgssw='kgss --watch' +alias kgsswide='kgss -o wide' +alias kess='kubectl edit statefulset' +alias kdss='kubectl describe statefulset' +alias kdelss='kubectl delete statefulset' +alias ksss='kubectl scale statefulset' +alias krsss='kubectl rollout status statefulset' + +# Port forwarding +alias kpf="kubectl port-forward" + +# Tools for accessing all information +alias kga='kubectl get all' +alias kgaa='kubectl get all --all-namespaces' + +# Logs +alias kl='kubectl logs' +alias kl1h='kubectl logs --since 1h' +alias kl1m='kubectl logs --since 1m' +alias kl1s='kubectl logs --since 1s' +alias klf='kubectl logs -f' +alias klf1h='kubectl logs --since 1h -f' +alias klf1m='kubectl logs --since 1m -f' +alias klf1s='kubectl logs --since 1s -f' + +# File copy +alias kcp='kubectl cp' + +# Node Management +alias kgno='kubectl get nodes' +alias keno='kubectl edit node' +alias kdno='kubectl describe node' +alias kdelno='kubectl delete node' + +# PVC management. +alias kgpvc='kubectl get pvc' +alias kgpvca='kubectl get pvc --all-namespaces' +alias kgpvcw='kgpvc --watch' +alias kepvc='kubectl edit pvc' +alias kdpvc='kubectl describe pvc' +alias kdelpvc='kubectl delete pvc' + +# Service account management. +alias kdsa="kubectl describe sa" +alias kdelsa="kubectl delete sa" + +# DaemonSet management. +alias kgds='kubectl get daemonset' +alias kgdsw='kgds --watch' +alias keds='kubectl edit daemonset' +alias kdds='kubectl describe daemonset' +alias kdelds='kubectl delete daemonset' + +# CronJob management. +alias kgcj='kubectl get cronjob' +alias kecj='kubectl edit cronjob' +alias kdcj='kubectl describe cronjob' +alias kdelcj='kubectl delete cronjob' + +# Job management. +alias kgj='kubectl get job' +alias kej='kubectl edit job' +alias kdj='kubectl describe job' +alias kdelj='kubectl delete job' \ No newline at end of file diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 21c0ace1..b70e488f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,28 +3,60 @@ "isRoot": true, "tools": { "altcover.global": { - "version": "8.3.838", + "version": "8.8.74", "commands": [ "altcover" - ] + ], + "rollForward": false }, "cake.tool": { "version": "2.3.0", "commands": [ "dotnet-cake" - ] + ], + "rollForward": false }, "dotnet-format": { "version": "5.1.250801", "commands": [ "dotnet-format" - ] + ], + "rollForward": false }, "csharpier": { - "version": "0.22.1", + "version": "0.28.2", "commands": [ "dotnet-csharpier" - ] + ], + "rollForward": false + }, + "swashbuckle.aspnetcore.cli": { + "version": "6.6.2", + "commands": [ + "swagger" + ], + "rollForward": false + }, + "microsoft.tye": { + "version": "0.11.0-alpha.22111.1", + "commands": [ + "tye" + ], + "rollForward": false + }, + "dotnet-ef": { + "version": "8.0.7", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + }, + "dotnet-outdated-tool": { + "version": "4.6.4", + "commands": [ + "dotnet-outdated" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5d8a1fdf..2487953a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,5 @@ -# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu/.devcontainer/base.Dockerfile -# https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu/.devcontainer -# https://github.com/devcontainers/images/tree/main/src/dotnet +# https://containers.dev/guide/dockerfile#dockerfile -# Update 'VARIANT' to pick an Ubuntu version: jammy / ubuntu-22.04, focal / ubuntu-20.04, bionic /ubuntu-18.04 -# Use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon. -ARG VARIANT="jammy" -FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} \ No newline at end of file +FROM mcr.microsoft.com/devcontainers/dotnet:latest + +# Add additional commands \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8aad0b8c..458b38be 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,36 +1,113 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: -// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu -// https://github.com/microsoft/vscode-remote-try-dotnetcore/blob/main/.devcontainer/devcontainer.json +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +// https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/setting-up-your-dotnet-project-for-codespaces +// https://github.com/microsoft/vscode-remote-try-dotnet +// https://dev.to/this-is-learning/set-up-github-codespaces-for-a-net-8-application-5999 +// https://audacioustux.notion.site/Getting-Started-with-Devcontainer-c727dbf9d56f4d6b9b0ef87b3111693f { - "name": "Ubuntu", - "build": { - "dockerfile": "Dockerfile", - // Update 'VARIANT' to pick an Ubuntu version: jammy / ubuntu-22.04, focal / ubuntu-20.04, bionic /ubuntu-18.04 - // Use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon. - "args": { - "VARIANT": "ubuntu-22.04" - } - }, - "extensions": [ - "ms-dotnettools.csharp", - "mutantdino.resourcemonitor", - "humao.rest-client", - "dzhavat.bracket-pair-toggler", - "Trottero.dotnetwatchattach", - "ms-azuretools.vscode-docker", - "vivaxy.vscode-conventional-commits", - "emmanuelbeziat.vscode-great-icons", - "fernandoescolar.vscode-solution-explorer", - "mikestead.dotenv", - "ms-vscode.vs-keybindings" - ], - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "uname -a", - // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode", - "features": { - "docker-in-docker": "latest" - } -} \ No newline at end of file + "name": "Food Delivery Microservices", + // use existing dev container templates. More info: https://containers.dev/guide/dockerfile, https://containers.dev/templates + //"image": "mcr.microsoft.com/devcontainers/dotnet:1-7.0", + // use a Dockerfile file. More info: https://containers.dev/guide/dockerfile#dockerfile + // "build": { + // // Path is relative to the devcontainer.json file. + // "dockerfile": "Dockerfile" + // }, + // using a Dockerfile with Docker Compose, https://containers.dev/guide/dockerfile#docker-compose-image + "dockerComposeFile": "docker-compose.yaml", + "service": "devcontainer", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + // https://github.com/devcontainers/features/tree/main/src/dotnet#dotnet-cli-dotnet + "ghcr.io/devcontainers/features/dotnet:2": { + // this version should be matched with global.json .net version for working vscode IntelliSense correctly + "version": "8.0.303", + "additionalVersions": "latest, 7.0.410, 8.0.303, 6.0.424", + "aspNetCoreRuntimeVersions": "latest, 7.0" + }, + // https://github.com/devcontainers/features/tree/main/src/github-cli + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "2" + }, + // https://github.com/devcontainers/features/tree/main/src/powershell + "ghcr.io/devcontainers/features/powershell:1": { + "version": "latest" + }, + // https://github.com/devcontainers/features/tree/main/src/node + "ghcr.io/devcontainers/features/node:1": {}, + // // https://github.com/devcontainers/features/tree/main/src/kubectl-helm-minikube + // "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}, + // // https://github.com/devcontainers/features/tree/main/src/terraform + // "ghcr.io/devcontainers/features/terraform:1": {}, + // https://github.com/devcontainers/features/tree/main/src/docker-in-docker + // https://devopscube.com/run-docker-in-docker/ + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/git:1": {}, + // https://github.com/devcontainers/features/tree/main/src/common-utils + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": true + } + }, + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + "settings": { + "git.autofetch": true, + "files.autoSave": "onFocusChange", + "editor.formatOnSave": true, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "explorer.autoReveal": true, + "resmon.show.cpufreq": false, + "dotnet.defaultSolution": "food-delivery.sln", + "dotnet.server.startTimeout": 60000, + "omnisharp.projectLoadTimeout": 60, + "workbench.colorTheme": "Visual Studio Light", + "workbench.iconTheme": "material-icon-theme", + "editor.minimap.enabled": false, + "editor.fontFamily": "'MesloLGM Nerd Font', 'Droid Sans Mono', 'monospace', 'Droid Sans Fallback', 'Consolas'", + "editor.fontSize": 14, + "explorer.confirmDelete": false, + "terminal.integrated.defaultProfile.windows": "PowerShell", + "terminal.integrated.defaultProfile.linux": "zsh", + "powershell.cwd": "~", + "terminal.external.windowsExec": "%LOCALAPPDATA%\\Microsoft\\WindowsApps\\pwsh.exe" + }, + "extensions": [ + "streetsidesoftware.code-spell-checker", + "ms-dotnettools.csdevkit", + "mutantdino.resourcemonitor", + "humao.rest-client", + "dzhavat.bracket-pair-toggler", + "ms-azuretools.vscode-docker", + "vivaxy.vscode-conventional-commits", + "emmanuelbeziat.vscode-great-icons", + "ms-vscode.vs-keybindings", + "GitHub.vscode-github-actions", + "PKief.material-icon-theme", + "EditorConfig.EditorConfig", + "DavidAnson.vscode-markdownlint" + ] + } + }, + "hostRequirements": { + "cpus": 2, + "memory": "8gb", + "storage": "32gb" + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5001], + // "portsAttributes": { + // "5001": { + // "protocol": "https" + // } + // } + // https://containers.dev/implementors/json_reference/#lifecycle-scripts + "updateContentCommand": "chmod +x .devcontainer/scripts/update.sh", + "postCreateCommand": "chmod +x .devcontainer/scripts/post-create.sh" + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 00000000..8d8adc20 --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,26 @@ +# https://containers.dev/guide/dockerfile#docker-compose-image +# https://containers.dev/guide/dockerfile#docker-compose-dockerfile +services: + devcontainer: + build: + context: . + volumes: + - ../..:/workspaces:rw,cached + init: true + command: sleep infinity + +# services: +# devcontainer: +# image: mcr.microsoft.com/devcontainers/dotnet:3.1.0 +# volumes: +# - ../..:/workspaces:cached +# network_mode: service:db +# command: sleep infinity + +# db: +# image: postgres:latest +# restart: unless-stopped +# environment: +# POSTGRES_PASSWORD: postgres +# POSTGRES_USER: postgres +# POSTGRES_DB: postgres diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh new file mode 100644 index 00000000..e84ede05 --- /dev/null +++ b/.devcontainer/scripts/post-create.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eax + +# Run the setup_fonts.sh script +./setup-fonts.sh diff --git a/.devcontainer/scripts/setup-fonts.sh b/.devcontainer/scripts/setup-fonts.sh new file mode 100644 index 00000000..cda8f124 --- /dev/null +++ b/.devcontainer/scripts/setup-fonts.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Download MesloLGM Nerd Font +wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/Meslo.zip -O MesloLGM.zip + +# Extract the font files +unzip MesloLGM.zip -d MesloLGM + +# Create the fonts directory if it doesn't exist +mkdir -p ~/.local/share/fonts + +# Move the font files to the fonts directory +mv MesloLGM/*.ttf ~/.local/share/fonts/ + +# Update the font cache +fc-cache -fv + +# Clean up +rm -rf MesloLGM.zip MesloLGM + +# Verify installation +fc-list | grep "MesloLGM" + +echo "Font setup completed." diff --git a/.devcontainer/scripts/update.sh b/.devcontainer/scripts/update.sh new file mode 100644 index 00000000..99a8bb9c --- /dev/null +++ b/.devcontainer/scripts/update.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +set -eax \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index a8e1e1ae..d238d3a2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -368,6 +368,9 @@ dotnet_diagnostic.sa1013.severity = Suggestion # A call to an instance member of the local class or a base class is not prefixed with 'this.', within a C# code file. dotnet_diagnostic.sa1101.severity = None +# The keywords within the declaration of an element do not follow a standard ordering scheme. +dotnet_diagnostic.SA1206.severity = None + ################################################################################## ## https://github.com/meziantou/Meziantou.Analyzer/tree/main/docs ## Meziantou.Analyzer diff --git a/.github/actions/build-test/action.yml b/.github/actions/build-test/action.yml index 10666292..768d0ff1 100644 --- a/.github/actions/build-test/action.yml +++ b/.github/actions/build-test/action.yml @@ -22,14 +22,14 @@ inputs: # https://github.com/coverlet-coverage/coverlet/blob/master/Documentation/MSBuildIntegration.md#filters coverage-exclude: description: Coverage exclude filter - default: "[BuildingBlocks.*]*%2c[ECommerce.Services.Shared]*" + default: "[BuildingBlocks.*]*%2c[FoodDelivery.Services.Shared]*" # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test#filter-option-details unit-test-filter: description: Unit tests filter - default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~ECommerce.Services)" + default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~FoodDelivery.Services)" integration-test-filter: description: Integration tests filter - default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~ECommerce.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" + default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~FoodDelivery.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" reports-path: description: Test report path required: true @@ -60,7 +60,7 @@ runs: - name: Call Composite Action test uses: ./.github/actions/test - if: ${{ success() && inputs.tests-path != ''}} + if: ${{ success() && inputs.tests-path != ''}} id: test-step with: tests-path: ${{ inputs.tests-path }} diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml index 7fe2ecf9..ba642ba6 100644 --- a/.github/actions/docker-build-push/action.yml +++ b/.github/actions/docker-build-push/action.yml @@ -19,7 +19,7 @@ inputs: description: Docker registry to push default: "ghcr.io" registry-endpoint: - description: Image registry repo e.g. username/ecommerce-microservices + description: Image registry repo e.g. username/food-delivery-microservices required: true registry-username: description: Registry username @@ -35,15 +35,14 @@ inputs: runs: using: "composite" steps: - - name: Info shell: bash run: | - echo actor is: "${{ github.actor }}" - echo registry is: "${{ inputs.registry }}" - echo docker-file-path is: "${{ inputs.docker-file-path }}" - echo service-name is: "${{ inputs.service-name }}" - echo application-version is: "${{ inputs.application-version }}" + echo actor is: "${{ github.actor }}" + echo registry is: "${{ inputs.registry }}" + echo docker-file-path is: "${{ inputs.docker-file-path }}" + echo service-name is: "${{ inputs.service-name }}" + echo application-version is: "${{ inputs.application-version }}" - name: Check Inputs shell: bash @@ -72,7 +71,7 @@ runs: run: | docker build . --tag ${{ inputs.registry }}/${{ inputs.registry-endpoint }}/${{ inputs.service-name }}:${{ inputs.application-version }} -f "${{ inputs.docker-file-path }}" - - name: 'Login to GitHub Container Registry' + - name: "Login to GitHub Container Registry" uses: docker/login-action@v1 if: success() with: diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 2c0302e7..22f60cc9 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -15,14 +15,14 @@ inputs: # https://github.com/coverlet-coverage/coverlet/blob/master/Documentation/MSBuildIntegration.md#filters coverage-exclude: description: Coverage exclude filter - default: "[BuildingBlocks.*]*%2c[ECommerce.Services.Shared]*" + default: "[BuildingBlocks.*]*%2c[FoodDelivery.Services.Shared]*" # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test#filter-option-details unit-test-filter: description: Unit tests filter - default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~ECommerce.Services)" + default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~FoodDelivery.Services)" integration-test-filter: description: Integration tests filter - default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~ECommerce.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" + default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~FoodDelivery.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" reports-path: description: Test report path required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1b51d0a1..386d1b9c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,14 @@ # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: monthly - package-ecosystem: nuget - directory: '/' + directory: "/" schedule: - interval: daily + interval: monthly reviewers: - mehdihadeli assignees: @@ -20,4 +24,3 @@ updates: open-pull-requests-limit: 5 # By default, Dependabot checks for manifest files on the default branch and raises pull requests for version updates against this branch. Use target-branch to specify a different branch target-branch: "develop" - diff --git a/.github/workflows/catalogs.yml b/.github/workflows/catalogs.yml new file mode 100644 index 00000000..7b2b695b --- /dev/null +++ b/.github/workflows/catalogs.yml @@ -0,0 +1,403 @@ +# Linting workflow: https://github.com/rhysd/actionlint + +name: Catalogs-CI-CD + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push + # Runs your workflow when you push a commit or tag. + push: + branches: + - develop + - main + - preview + - beta + - fix/* + - build/* + - test/* + - ci/* + - feat/* + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Catalogs/** + - src/BuildingBlocks/** + - tests/Services/Catalogs/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/catalogs.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + pull_request: + branches: + - develop + - main + - preview + - beta + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Catalogs/** + - src/BuildingBlocks/** + - tests/Services/Catalogs/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/catalogs.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs + # https://github.blog/changelog/2021-11-10-github-actions-input-types-for-manual-workflows/ + # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow + # Allows us to run this workflow manually from the Actions tab + # To manually trigger a workflow, use the workflow_dispatch event. You can manually trigger a workflow run using the GitHub API, GitHub CLI, or GitHub browser interface + # Note: To trigger the workflow_dispatch event, our workflow must be in the default branch + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'info' + type: choice + options: + - info + - warning + - debug + should-publish: + required: true + default: true + description: "Should publish docker and publish artifacts?" + type: boolean + should-publish-release-note: + required: true + default: true + description: "Should publish a release note?" + type: boolean + should-deploy: + required: true + default: true + description: "Should deploy?" + type: boolean +# # we don't use this here, I detect environment based on the branch +# environment: +# description: "Environment to run" +# type: environment +# required: true + +# https://docs.github.com/en/actions/learn-github-actions/variables#defining-environment-variables-for-a-single-workflow +# https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations +# https://docs.github.com/en/actions/learn-github-actions/variables +# Any environment variables set in an env context defined at the workflow level in the caller workflow are not propagated to the called workflow +env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} + BRANCH_NAME: ${{ github.ref_name }} + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/using-workflows/about-workflows +jobs: + pre-check: + runs-on: ubuntu-latest + + # https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs + # Job outputs containing expressions are evaluated on the runner at the end of each job + outputs: + environment-name: ${{ steps.environment-name-step.outputs.environment-name }} + + # https://itnext.io/automate-your-integration-tests-and-semantic-releases-with-github-actions-43875ad83092 + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # we should not filter on head commit message types like 'chore', 'docs' because it is possible it our latest SHA commit in integration branches like develop and main be these types and it will skips whole of our trigger + if: | + github.actor != 'dependabot[bot]' + + steps: + - name: Conventional Commits Checks + uses: webiny/action-conventional-commits@v1.1.0 + + - name: Job Info + if: success() + run: | + echo "pre-check is successful." + echo workspace is: ${{ github.workspace }} + echo "is workflow_dispatch event? ${{ github.event_name == 'workflow_dispatch' }}" + echo "is push event? ${{ github.event_name == 'push' }}" + echo "is pull request event? ${{ github.event_name == 'pull_request' }}" + echo "pull_request.head.ref is: ${{ github.event.pull_request.head.ref }}" + echo "github.ref_name is: ${{ github.ref_name }}" + echo "github.ref is: ${{ github.ref }}" + echo "github.head_ref is: ${{ github.head_ref }}" + echo "should publish in dispatch mode? ${{ github.event.inputs.should-publish }}" + + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://stackoverflow.com/questions/63117454/how-to-set-workflow-env-variables-depending-on-branch + # https://hungvu.tech/advanced-github-actions-conditional-workflow + - name: Set Environment For Branch + if: success() + id: environment-name-step + run: | + if [[ $GITHUB_REF == 'refs/heads/main' ]]; then + echo "environment-name=production" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/develop' ]]; then + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/preview' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/beta' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + else + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + fi + + ### CI + call-build-test: + needs: pre-check + + permissions: + checks: write # for test-reporter + contents: read + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # we should not filter on head commit message like 'build', 'test' because it is possible beside of this job, it triggers also 'call-build-test-push' job when we commit on main branches (just filter on branches) + if: | + !(contains(github.event.head_commit.message, '[skip ci]')) && + (github.event_name == 'pull_request' || !contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'false')) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.CATALOGS_SERVICE_TESTS_PATH }} # tests/Services/Catalogs + project-path: ${{ vars.CATALOGS_SERVICE_PROJECT_PATH }} # src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api + service-name: ${{ vars.CATALOGS_SERVICE_NAME }} # catalogs-service + docker-file-path: ${{ vars.CATALOGS_SERVICE_DOCKER_FILE_PATH }} # src/Services/Catalogs/Dockerfile + + call-build-test-push: + needs: pre-check + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # input boolean type should compare with 'true' or 'false' string + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'true'))) + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + packages: write # for publishing packages + pull-requests: write # app-version pull request + contents: write # for publishing in dry-run mode + checks: write # for test-reporter + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test-push.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.CATALOGS_SERVICE_TESTS_PATH }} # tests/Services/Catalogs + project-path: ${{ vars.CATALOGS_SERVICE_PROJECT_PATH }} # src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api + service-name: ${{ vars.CATALOGS_SERVICE_NAME }} # catalogs-service + docker-file-path: ${{ vars.CATALOGS_SERVICE_DOCKER_FILE_PATH }} # src/Services/Catalogs/Dockerfile + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + + ### CD + # runs only for cd part when we have a push + workflow-info: + runs-on: ubuntu-latest + needs: [pre-check, call-build-test-push] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + steps: + - name: create output dir + run: mkdir -p "output" + + # https://github.com/actions/download-artifact#download-all-artifacts + # download artifacts in same workflow (artifacts for before job 'call-build-test-push') + - name: Download All Artifacts For Push and workflow_dispatch + if: (github.event_name == 'workflow_dispatch' || github.event_name == 'push') + uses: actions/download-artifact@v3 + with: + path: artifacts + + # https://github.com/dawidd6/action-download-artifact + # for artifacts form another workflows we should get that artifact with github Rest call and download-artifact@v3 doesn't work + - name: Download All Artifacts For workflow_run + if: (github.event_name == 'workflow_run') + uses: dawidd6/action-download-artifact@v2 + with: + github_token: ${{secrets.GITHUB_TOKEN}} + # check the workflow run to whether it has an artifact then will get the last available artifact from the previous workflow + check_artifacts: true + workflow_conclusion: success + # previous success workflow in workflow_run after complete + workflow: ${{ github.event.workflow_run.workflow_id }} + ## uploaded artifact name, will download all artifacts if not specified + # name: artifact_name + path: artifacts + + - name: dir + if: success() + run: ls -R "${{ github.workspace }}/artifacts" + + - name: Get CD Status + if: success() + id: cd-status-step + run: | + CD_STATUS_FILE="artifacts/${{ vars.CATALOGS_SERVICE_NAME }}_cd_status_artifacts/cd_status.txt" + if [ -f $CD_STATUS_FILE ]; then + CD_STATUS=$(cat $CD_STATUS_FILE) + echo "cd-status=$CD_STATUS" >> "$GITHUB_OUTPUT" + if [ $CD_STATUS != 'true' ]; then + echo "CD status is false, so CD will be skipped" + exit 1 + fi + echo "cd-status is true, CD will be executed" + else + echo "Error: CD_STATUS_FILE not found." + exit 1 + fi + + - name: Get CI Application Version + if: success() + id: application-version-step + run: | + VERSION_FILE="artifacts/${{ vars.CATALOGS_SERVICE_NAME }}_version_artifacts/version_name.txt" + if [ -f $VERSION_FILE ]; then + VERSION=$(cat $VERSION_FILE) + echo "application-version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Error: VERSION_FILE not found." + fi + + - name: Get CI Image Name + if: success() + id: image-name-step + run: | + IMAGE_NAME_FILE="artifacts/${{ vars.CATALOGS_SERVICE_NAME }}_image_artifacts/image_name.txt" + if [ -f $IMAGE_NAME_FILE ]; then + IMAGE_NAME=$(cat $IMAGE_NAME_FILE) + echo "image-name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + else + echo "Error: IMAGE_NAME_FILE not found." + fi + + # https://askubuntu.com/questions/86849/how-to-unzip-a-zip-file-from-the-terminal + - name: unzip artifacts + if: success() + run: | + unzip "artifacts/${{ vars.CATALOGS_SERVICE_NAME }}_test_artifacts/test-results.zip" -d "output" + + - name: Ls Output Files + if: success() + run: ls -R ${{ github.workspace }}/output + + # typically release notes are published as part of the Continuous Deployment (CD) process, after the software has been built, tested, and deployed to production. + # It's best practice to publish release notes before deploying an app to the cloud. This allows users to be informed about what changes have been made and what to expect in the latest version. + call-release: + needs: [ pre-check, workflow-info ] + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + contents: write # to be able to publish a GitHub release and tags + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + uses: ./.github/workflows/reusable-release.yml + secrets: inherit + + # https://docs.github.com/en/actions/deployment/about-deployments/deploying-with-github-actions + # https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment + # https://www.trywilco.com/post/wilco-ci-cd-github-heroku + # https://limeii.github.io/2022/11/deploy-to-azure-appservice-with-github-actions/ + # https://limeii.github.io/2022/11/deploy-on-multiple-environment-with-github-actions/ + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://colinsalmcorner.com/musings-on-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run + # we could use workflow_run and `completed` event that triggered by CI workflow here, This event will only trigger a workflow run if the workflow file is on the default branch. + call-deploy: + needs: [pre-check, call-release] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-deploy == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-deploy.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit + with: + environment-name: ${{ needs.pre-check.outputs.environment-name }} + release-version: ${{ needs.call-release.outputs.release-version }} + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + service-name: ${{ vars.CATALOGS_SERVICE_NAME }} # catalogs-service + + diff --git a/.github/workflows/customers.yml b/.github/workflows/customers.yml new file mode 100644 index 00000000..3dd205e4 --- /dev/null +++ b/.github/workflows/customers.yml @@ -0,0 +1,404 @@ +# Linting workflow: https://github.com/rhysd/actionlint + +name: Customers-CI-CD + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push + # Runs your workflow when you push a commit or tag. + push: + branches: + - develop + - main + - preview + - beta + - devops/ci + - fix/* + - feat/* + - test/* + - build/* + - ci/* + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Customers/** + - src/BuildingBlocks/** + - tests/Services/Customers/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/customers.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + pull_request: + branches: + - develop + - main + - preview + - beta + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Customers/** + - src/BuildingBlocks/** + - tests/Services/Customers/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/customers.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs + # https://github.blog/changelog/2021-11-10-github-actions-input-types-for-manual-workflows/ + # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow + # Allows us to run this workflow manually from the Actions tab + # To manually trigger a workflow, use the workflow_dispatch event. You can manually trigger a workflow run using the GitHub API, GitHub CLI, or GitHub browser interface + # Note: To trigger the workflow_dispatch event, our workflow must be in the default branch + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'info' + type: choice + options: + - info + - warning + - debug + should-publish: + required: true + default: true + description: "Should publish docker and publish artifacts?" + type: boolean + should-publish-release-note: + required: true + default: true + description: "Should publish a release note?" + type: boolean + should-deploy: + required: true + default: true + description: "Should deploy?" + type: boolean +# # we don't use this here, I detect environment based on the branch +# environment: +# description: "Environment to run" +# type: environment +# required: true + +# https://docs.github.com/en/actions/learn-github-actions/variables#defining-environment-variables-for-a-single-workflow +# https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations +# https://docs.github.com/en/actions/learn-github-actions/variables +# Any environment variables set in an env context defined at the workflow level in the caller workflow are not propagated to the called workflow +env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} + BRANCH_NAME: ${{ github.ref_name }} + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/using-workflows/about-workflows +jobs: + + pre-check: + runs-on: ubuntu-latest + + # https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs + # Job outputs containing expressions are evaluated on the runner at the end of each job + outputs: + environment-name: ${{ steps.environment-name-step.outputs.environment-name }} + + # https://itnext.io/automate-your-integration-tests-and-semantic-releases-with-github-actions-43875ad83092 + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # we should not filter on head commit message types like 'chore', 'docs' because it is possible it our latest SHA commit in integration branches like develop and main be these types and it will skips whole of our trigger + if: | + github.actor != 'dependabot[bot]' + + steps: + - name: Conventional Commits Checks + uses: webiny/action-conventional-commits@v1.1.0 + + - name: Job Info + if: success() + run: | + echo "pre-check is successful." + echo workspace is: ${{ github.workspace }} + echo "is workflow_dispatch event? ${{ github.event_name == 'workflow_dispatch' }}" + echo "is push event? ${{ github.event_name == 'push' }}" + echo "is pull request event? ${{ github.event_name == 'pull_request' }}" + echo "pull_request.head.ref is: ${{ github.event.pull_request.head.ref }}" + echo "github.ref_name is: ${{ github.ref_name }}" + echo "github.ref is: ${{ github.ref }}" + echo "github.head_ref is: ${{ github.head_ref }}" + echo "should publish in dispatch mode? ${{ github.event.inputs.should-publish }}" + + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://stackoverflow.com/questions/63117454/how-to-set-workflow-env-variables-depending-on-branch + # https://hungvu.tech/advanced-github-actions-conditional-workflow + - name: Set Environment For Branch + if: success() + id: environment-name-step + run: | + if [[ $GITHUB_REF == 'refs/heads/main' ]]; then + echo "environment-name=production" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/develop' ]]; then + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/preview' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/beta' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + else + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + fi + + ### CI + call-build-test: + needs: pre-check + + permissions: + checks: write # for test-reporter + contents: read + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # we should not filter on head commit message like 'build', 'test' because it is possible beside of this job, it triggers also 'call-build-test-push' job when we commit on main branches (just filter on branches) + if: | + !(contains(github.event.head_commit.message, '[skip ci]')) && + (github.event_name == 'pull_request' || !contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'false')) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.CUSTOMERS_SERVICE_TESTS_PATH }} # tests/Services/Customers + project-path: ${{ vars.CUSTOMERS_SERVICE_PROJECT_PATH }} # src/Services/Customers/FoodDelivery.Services.Customers.Api + service-name: ${{ vars.CUSTOMERS_SERVICE_NAME }} # customers-service + docker-file-path: ${{ vars.CUSTOMERS_SERVICE_DOCKER_FILE_PATH }} # src/Services/Customers/Dockerfile + + call-build-test-push: + needs: pre-check + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + packages: write # for publishing packages + pull-requests: write # app-version pull request + contents: write # for publishing in dry-run mode + checks: write # for test-reporter + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # input boolean type should compare with 'true' or 'false' string + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test-push.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.CUSTOMERS_SERVICE_TESTS_PATH }} # tests/Services/Customers + project-path: ${{ vars.CUSTOMERS_SERVICE_PROJECT_PATH }} # src/Services/Customers/FoodDelivery.Services.Customers.Api + service-name: ${{ vars.CUSTOMERS_SERVICE_NAME }} # customers-service + docker-file-path: ${{ vars.CUSTOMERS_SERVICE_DOCKER_FILE_PATH }} # src/Services/Customers/Dockerfile + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + + ### CD + # runs only for cd part when we have a push + workflow-info: + runs-on: ubuntu-latest + needs: [pre-check, call-build-test-push] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + steps: + - name: create output dir + run: mkdir -p "output" + + # https://github.com/actions/download-artifact#download-all-artifacts + # download artifacts in same workflow (artifacts for before job 'call-build-test-push') + - name: Download All Artifacts For Push and workflow_dispatch + if: (github.event_name == 'workflow_dispatch' || github.event_name == 'push') + uses: actions/download-artifact@v3 + with: + path: artifacts + + # https://github.com/dawidd6/action-download-artifact + # for artifacts form another workflows we should get that artifact with github Rest call and download-artifact@v3 doesn't work + - name: Download All Artifacts For workflow_run + if: (github.event_name == 'workflow_run') + uses: dawidd6/action-download-artifact@v2 + with: + github_token: ${{secrets.GITHUB_TOKEN}} + # check the workflow run to whether it has an artifact then will get the last available artifact from the previous workflow + check_artifacts: true + workflow_conclusion: success + # previous success workflow in workflow_run after complete + workflow: ${{ github.event.workflow_run.workflow_id }} + ## uploaded artifact name, will download all artifacts if not specified + # name: artifact_name + path: artifacts + + - name: dir + if: success() + run: ls -R "${{ github.workspace }}/artifacts" + + - name: Get CD Status + if: success() + id: cd-status-step + run: | + CD_STATUS_FILE="artifacts/${{ vars.CUSTOMERS_SERVICE_NAME }}_cd_status_artifacts/cd_status.txt" + if [ -f $CD_STATUS_FILE ]; then + CD_STATUS=$(cat $CD_STATUS_FILE) + echo "cd-status=$CD_STATUS" >> "$GITHUB_OUTPUT" + if [ $CD_STATUS != 'true' ]; then + echo "CD status is false, so CD will be skipped" + exit 1 + fi + echo "cd-status is true, CD will be executed" + else + echo "Error: CD_STATUS_FILE not found." + exit 1 + fi + + - name: Get CI Application Version + if: success() + id: application-version-step + run: | + VERSION_FILE="artifacts/${{ vars.CUSTOMERS_SERVICE_NAME }}_version_artifacts/version_name.txt" + if [ -f $VERSION_FILE ]; then + VERSION=$(cat $VERSION_FILE) + echo "application-version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Error: VERSION_FILE not found." + fi + + - name: Get CI Image Name + if: success() + id: image-name-step + run: | + IMAGE_NAME_FILE="artifacts/${{ vars.CUSTOMERS_SERVICE_NAME }}_image_artifacts/image_name.txt" + if [ -f $IMAGE_NAME_FILE ]; then + IMAGE_NAME=$(cat $IMAGE_NAME_FILE) + echo "image-name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + else + echo "Error: IMAGE_NAME_FILE not found." + fi + + # https://askubuntu.com/questions/86849/how-to-unzip-a-zip-file-from-the-terminal + - name: unzip artifacts + if: success() + run: | + unzip "artifacts/${{ vars.CUSTOMERS_SERVICE_NAME }}_test_artifacts/test-results.zip" -d "output" + + - name: Ls Output Files + if: success() + run: ls -R ${{ github.workspace }}/output + + # typically release notes are published as part of the Continuous Deployment (CD) process, after the software has been built, tested, and deployed to production. + # It's best practice to publish release notes before deploying an app to the cloud. This allows users to be informed about what changes have been made and what to expect in the latest version. + call-release: + needs: [ pre-check, workflow-info ] + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + contents: write # to be able to publish a GitHub release and tags + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + uses: ./.github/workflows/reusable-release.yml + secrets: inherit + + # https://docs.github.com/en/actions/deployment/about-deployments/deploying-with-github-actions + # https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment + # https://www.trywilco.com/post/wilco-ci-cd-github-heroku + # https://limeii.github.io/2022/11/deploy-to-azure-appservice-with-github-actions/ + # https://limeii.github.io/2022/11/deploy-on-multiple-environment-with-github-actions/ + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://colinsalmcorner.com/musings-on-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run + # we could use workflow_run and `completed` event that triggered by CI workflow here, This event will only trigger a workflow run if the workflow file is on the default branch. + call-deploy: + needs: [pre-check, call-release] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-deploy == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-deploy.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit + with: + environment-name: ${{ needs.pre-check.outputs.environment-name }} + release-version: ${{ needs.call-release.outputs.release-version }} + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + service-name: ${{ vars.CUSTOMERS_SERVICE_NAME }} # customer-service + diff --git a/.github/workflows/first-interaction.yml b/.github/workflows/first-interaction.yml index 9e9db9e2..79867c4c 100644 --- a/.github/workflows/first-interaction.yml +++ b/.github/workflows/first-interaction.yml @@ -1,7 +1,7 @@ name: First interaction on: pull_request_target: - types: + types: - opened issues: types: @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest name: First interaction steps: - - uses: actions/first-interaction@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Welcome to ecommerece-microservice. Thank you ${{ github.event.pull_request.user.login }} for reporting your first issue. Please check out our [contributor guide](https://github.com/mehdihadeli/ecommerce-microservices/blob/develop/CONTRIBUTION.md).' - pr-message: 'Thank you ${{ github.event.pull_request.user.login }} for your first pull request to ecommerece-microservice repository. Please check out our [contributors guide](https://github.com/mehdihadeli/ecommerce-microservices/blob/develop/CONTRIBUTION.md).' + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: "Welcome to food-delivery-microservice. Thank you ${{ github.event.pull_request.user.login }} for reporting your first issue. Please check out our [contributor guide](https://github.com/mehdihadeli/food-delivery-microservices/blob/develop/CONTRIBUTION.md)." + pr-message: "Thank you ${{ github.event.pull_request.user.login }} for your first pull request to food-delivery-microservice repository. Please check out our [contributors guide](https://github.com/mehdihadeli/food-delivery-microservices/blob/develop/CONTRIBUTION.md)." diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml new file mode 100644 index 00000000..9efae4ee --- /dev/null +++ b/.github/workflows/gateway.yml @@ -0,0 +1,388 @@ +# Linting workflow: https://github.com/rhysd/actionlint + +name: Gateway-CI-CD + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push + # Runs your workflow when you push a commit or tag. + push: + branches: + - develop + - main + - preview + - beta + - devops/ci + - fix/* + - feat/* + - test/* + - build/* + - ci/* + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/ApiGateway/** + - src/BuildingBlocks/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/gateway.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + pull_request: + branches: + - develop + - main + - preview + - beta + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/ApiGateway/** + - src/BuildingBlocks/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/gateway.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs + # https://github.blog/changelog/2021-11-10-github-actions-input-types-for-manual-workflows/ + # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow + # Allows us to run this workflow manually from the Actions tab + # To manually trigger a workflow, use the workflow_dispatch event. You can manually trigger a workflow run using the GitHub API, GitHub CLI, or GitHub browser interface + # Note: To trigger the workflow_dispatch event, our workflow must be in the default branch + workflow_dispatch: + inputs: + logLevel: + description: "Log level" + required: true + default: "info" + type: choice + options: + - info + - warning + - debug + should-publish: + required: true + default: true + description: "Should publish docker and publish artifacts?" + type: boolean + should-publish-release-note: + required: true + default: true + description: "Should publish a release note?" + type: boolean + should-deploy: + required: true + default: true + description: "Should deploy?" + type: boolean +# # we don't use this here, I detect environment based on the branch +# environment: +# description: "Environment to run" +# type: environment +# required: true + +# https://docs.github.com/en/actions/learn-github-actions/variables#defining-environment-variables-for-a-single-workflow +# https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations +# https://docs.github.com/en/actions/learn-github-actions/variables +# Any environment variables set in an env context defined at the workflow level in the caller workflow are not propagated to the called workflow +env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} + BRANCH_NAME: ${{ github.ref_name }} + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/using-workflows/about-workflows +jobs: + pre-check: + runs-on: ubuntu-latest + + # https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs + # Job outputs containing expressions are evaluated on the runner at the end of each job + outputs: + environment-name: ${{ steps.environment-name-step.outputs.environment-name }} + + # https://itnext.io/automate-your-integration-tests-and-semantic-releases-with-github-actions-43875ad83092 + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # we should not filter on head commit message types like 'chore', 'docs' because it is possible it our latest SHA commit in integration branches like develop and main be these types and it will skips whole of our trigger + if: | + github.actor != 'dependabot[bot]' + + steps: + - name: Conventional Commits Checks + uses: webiny/action-conventional-commits@v1.1.0 + + - name: Job Info + if: success() + run: | + echo "pre-check is successful." + echo workspace is: ${{ github.workspace }} + echo "is workflow_dispatch event? ${{ github.event_name == 'workflow_dispatch' }}" + echo "is push event? ${{ github.event_name == 'push' }}" + echo "is pull request event? ${{ github.event_name == 'pull_request' }}" + echo "pull_request.head.ref is: ${{ github.event.pull_request.head.ref }}" + echo "github.ref_name is: ${{ github.ref_name }}" + echo "github.ref is: ${{ github.ref }}" + echo "github.head_ref is: ${{ github.head_ref }}" + echo "should publish in dispatch mode? ${{ github.event.inputs.should-publish }}" + + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://stackoverflow.com/questions/63117454/how-to-set-workflow-env-variables-depending-on-branch + # https://hungvu.tech/advanced-github-actions-conditional-workflow + - name: Set Environment For Branch + if: success() + id: environment-name-step + run: | + if [[ $GITHUB_REF == 'refs/heads/main' ]]; then + echo "environment-name=production" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/develop' ]]; then + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/preview' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/beta' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + else + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + fi + + ### CI + call-build-test: + needs: pre-check + + permissions: + checks: write # for test-reporter + contents: read + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # we should not filter on head commit message like 'build', 'test' because it is possible beside of this job, it triggers also 'call-build-test-push' job when we commit on main branches (just filter on branches) + if: | + !(contains(github.event.head_commit.message, '[skip ci]')) && + (github.event_name == 'pull_request' || !contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'false')) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + project-path: ${{ vars.GATEWAY_SERVICE_PROJECT_PATH }} # src/ApiGateway/FoodDelivery.ApiGateway + service-name: ${{ vars.GATEWAY_SERVICE_NAME }} # gateway-service + docker-file-path: ${{ vars.GATEWAY_SERVICE_DOCKER_FILE_PATH }} # src/ApiGateway/Dockerfile + + call-build-test-push: + needs: pre-check + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + packages: write # for publishing packages + pull-requests: write # app-version pull request + contents: write # for publishing in dry-run mode + checks: write # for test-reporter + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # input boolean type should compare with 'true' or 'false' string + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test-push.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + project-path: ${{ vars.GATEWAY_SERVICE_PROJECT_PATH }} # src/ApiGateway/FoodDelivery.ApiGateway + service-name: ${{ vars.GATEWAY_SERVICE_NAME }} # gateway-service + docker-file-path: ${{ vars.GATEWAY_SERVICE_DOCKER_FILE_PATH }} # src/ApiGateway/Dockerfile + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + + ### CD + # runs only for cd part when we have a push + workflow-info: + runs-on: ubuntu-latest + needs: [pre-check, call-build-test-push] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + steps: + - name: create output dir + run: mkdir -p "output" + + # https://github.com/actions/download-artifact#download-all-artifacts + # download artifacts in same workflow (artifacts for before job 'call-build-test-push') + - name: Download All Artifacts For Push and workflow_dispatch + if: (github.event_name == 'workflow_dispatch' || github.event_name == 'push') + uses: actions/download-artifact@v3 + with: + path: artifacts + + # https://github.com/dawidd6/action-download-artifact + # for artifacts form another workflows we should get that artifact with github Rest call and download-artifact@v3 doesn't work + - name: Download All Artifacts For workflow_run + if: (github.event_name == 'workflow_run') + uses: dawidd6/action-download-artifact@v2 + with: + github_token: ${{secrets.GITHUB_TOKEN}} + # check the workflow run to whether it has an artifact then will get the last available artifact from the previous workflow + check_artifacts: true + workflow_conclusion: success + # previous success workflow in workflow_run after complete + workflow: ${{ github.event.workflow_run.workflow_id }} + ## uploaded artifact name, will download all artifacts if not specified + # name: artifact_name + path: artifacts + + - name: dir + if: success() + run: ls -R "${{ github.workspace }}/artifacts" + + - name: Get CD Status + if: success() + id: cd-status-step + run: | + CD_STATUS_FILE="artifacts/${{ vars.GATEWAY_SERVICE_NAME }}_cd_status_artifacts/cd_status.txt" + if [ -f $CD_STATUS_FILE ]; then + CD_STATUS=$(cat $CD_STATUS_FILE) + echo "cd-status=$CD_STATUS" >> "$GITHUB_OUTPUT" + if [ $CD_STATUS != 'true' ]; then + echo "CD status is false, so CD will be skipped" + exit 1 + fi + echo "cd-status is true, CD will be executed" + else + echo "Error: CD_STATUS_FILE not found." + exit 1 + fi + + - name: Get CI Application Version + if: success() + id: application-version-step + run: | + VERSION_FILE="artifacts/${{ vars.GATEWAY_SERVICE_NAME }}_version_artifacts/version_name.txt" + if [ -f $VERSION_FILE ]; then + VERSION=$(cat $VERSION_FILE) + echo "application-version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Error: VERSION_FILE not found." + fi + + - name: Get CI Image Name + if: success() + id: image-name-step + run: | + IMAGE_NAME_FILE="artifacts/${{ vars.GATEWAY_SERVICE_NAME }}_image_artifacts/image_name.txt" + if [ -f $IMAGE_NAME_FILE ]; then + IMAGE_NAME=$(cat $IMAGE_NAME_FILE) + echo "image-name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + else + echo "Error: IMAGE_NAME_FILE not found." + fi + + # typically release notes are published as part of the Continuous Deployment (CD) process, after the software has been built, tested, and deployed to production. + # It's best practice to publish release notes before deploying an app to the cloud. This allows users to be informed about what changes have been made and what to expect in the latest version. + call-release: + needs: [pre-check, workflow-info] + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + contents: write # to be able to publish a GitHub release and tags + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + uses: ./.github/workflows/reusable-release.yml + secrets: inherit + + # https://docs.github.com/en/actions/deployment/about-deployments/deploying-with-github-actions + # https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment + # https://www.trywilco.com/post/wilco-ci-cd-github-heroku + # https://limeii.github.io/2022/11/deploy-to-azure-appservice-with-github-actions/ + # https://limeii.github.io/2022/11/deploy-on-multiple-environment-with-github-actions/ + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://colinsalmcorner.com/musings-on-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run + # we could use workflow_run and `completed` event that triggered by CI workflow here, This event will only trigger a workflow run if the workflow file is on the default branch. + call-deploy: + needs: [pre-check, call-release] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-deploy == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-deploy.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit + with: + environment-name: ${{ needs.pre-check.outputs.environment-name }} + release-version: ${{ needs.call-release.outputs.release-version }} + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + service-name: ${{ vars.GATEWAY_SERVICE_NAME }} # gateway-service diff --git a/.github/workflows/identity.yml b/.github/workflows/identity.yml new file mode 100644 index 00000000..161d50de --- /dev/null +++ b/.github/workflows/identity.yml @@ -0,0 +1,404 @@ +# Linting workflow: https://github.com/rhysd/actionlint + +name: Identity-CI-CD + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push + # Runs your workflow when you push a commit or tag. + push: + branches: + - develop + - main + - preview + - beta + - devops/ci + - fix/* + - feat/* + - test/* + - build/* + - ci/* + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Identity/** + - src/BuildingBlocks/** + - tests/Services/Identity/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/identity.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + pull_request: + branches: + - develop + - main + - preview + - beta + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-only-when-a-push-affects-specific-files + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore + paths: + - src/Services/Identity/** + - src/BuildingBlocks/** + - tests/Services/Identity/** + - .github/actions/** + - .github/workflows/back-merge.yml + - .github/workflows/conventional-commits.yml + - .github/workflows/identity.yml + - .github/workflows/reusable-build-test.yml + - .github/workflows/reusable-build-test-push.yml + - .github/workflows/reusable-release.yml + - .github/workflows/reusable-deploy.yml + - src/Directory.Build.props + - src/Directory.Packages.props + - src/Packages.props + - tests/Directory.Packages.props + - tests/Directory.Build.props + - .releaserc.yaml + + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs + # https://github.blog/changelog/2021-11-10-github-actions-input-types-for-manual-workflows/ + # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow + # Allows us to run this workflow manually from the Actions tab + # To manually trigger a workflow, use the workflow_dispatch event. You can manually trigger a workflow run using the GitHub API, GitHub CLI, or GitHub browser interface + # Note: To trigger the workflow_dispatch event, our workflow must be in the default branch + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'info' + type: choice + options: + - info + - warning + - debug + should-publish: + required: true + default: true + description: "Should publish docker and publish artifacts?" + type: boolean + should-publish-release-note: + required: true + default: true + description: "Should publish a release note?" + type: boolean + should-deploy: + required: true + default: true + description: "Should deploy?" + type: boolean +# # we don't use this here, I detect environment based on the branch +# environment: +# description: "Environment to run" +# type: environment +# required: true + + +# https://docs.github.com/en/actions/learn-github-actions/variables#defining-environment-variables-for-a-single-workflow +# https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations +# https://docs.github.com/en/actions/learn-github-actions/variables +# Any environment variables set in an env context defined at the workflow level in the caller workflow are not propagated to the called workflow +env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} + BRANCH_NAME: ${{ github.ref_name }} + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/using-workflows/about-workflows +jobs: + pre-check: + runs-on: ubuntu-latest + + # https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs + # Job outputs containing expressions are evaluated on the runner at the end of each job + outputs: + environment-name: ${{ steps.environment-name-step.outputs.environment-name }} + + # https://itnext.io/automate-your-integration-tests-and-semantic-releases-with-github-actions-43875ad83092 + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # we should not filter on head commit message types like 'chore', 'docs' because it is possible it our latest SHA commit in integration branches like develop and main be these types and it will skips whole of our trigger + if: | + github.actor != 'dependabot[bot]' + + steps: + - name: Conventional Commits Checks + uses: webiny/action-conventional-commits@v1.1.0 + + - name: Job Info + if: success() + run: | + echo "pre-check is successful." + echo workspace is: ${{ github.workspace }} + echo "is workflow_dispatch event? ${{ github.event_name == 'workflow_dispatch' }}" + echo "is push event? ${{ github.event_name == 'push' }}" + echo "is pull request event? ${{ github.event_name == 'pull_request' }}" + echo "pull_request.head.ref is: ${{ github.event.pull_request.head.ref }}" + echo "github.ref_name is: ${{ github.ref_name }}" + echo "github.ref is: ${{ github.ref }}" + echo "github.head_ref is: ${{ github.head_ref }}" + echo "should publish in dispatch mode? ${{ github.event.inputs.should-publish }}" + + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://stackoverflow.com/questions/63117454/how-to-set-workflow-env-variables-depending-on-branch + # https://hungvu.tech/advanced-github-actions-conditional-workflow + - name: Set Environment For Branch + if: success() + id: environment-name-step + run: | + if [[ $GITHUB_REF == 'refs/heads/main' ]]; then + echo "environment-name=production" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/develop' ]]; then + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/preview' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + elif [[ $GITHUB_REF == 'refs/heads/beta' ]]; then + echo "environment-name=staging" >> "$GITHUB_OUTPUT" + else + echo "environment-name=develop" >> "$GITHUB_OUTPUT" + fi + + ### CI + call-build-test: + needs: pre-check + + permissions: + checks: write # for test-reporter + contents: read + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # we should not filter on head commit message like 'build', 'test' because it is possible beside of this job, it triggers also 'call-build-test-push' job when we commit on main branches (just filter on branches) + if: | + !(contains(github.event.head_commit.message, '[skip ci]')) && + (github.event_name == 'pull_request' || !contains(fromJson('["develop", "main", "beta", "preview"]'), github.ref_name) || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'false')) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.IDENTITY_SERVICE_TESTS_PATH }} # tests/Services/Identity + project-path: ${{ vars.IDENTITY_SERVICE_PROJECT_PATH }} # src/Services/Identity/FoodDelivery.Services.Identity.Api + service-name: ${{ vars.IDENTITY_SERVICE_NAME }} # identity-service + docker-file-path: ${{ vars.IDENTITY_SERVICE_DOCKER_FILE_PATH }} # src/Services/Identity/Dockerfile + + call-build-test-push: + needs: pre-check + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + packages: write # for publishing packages + pull-requests: write # app-version pull request + contents: write # for publishing in dry-run mode + checks: write # for test-reporter + + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # input boolean type should compare with 'true' or 'false' string + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-build-test-push.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit # pass all secrets + with: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#using-inputs-and-secrets-in-a-reusable-workflow + # https://docs.github.com/en/actions/learn-github-actions/variables + # https://stackoverflow.com/questions/73305126/passing-env-variable-inputs-to-a-reusable-workflow + tests-path: ${{ vars.IDENTITY_SERVICE_TESTS_PATH }} # tests/Services/Identity + project-path: ${{ vars.IDENTITY_SERVICE_PROJECT_PATH }} # src/Services/Identity/FoodDelivery.Services.Identity.Api + service-name: ${{ vars.IDENTITY_SERVICE_NAME }} # identity-service + docker-file-path: ${{ vars.IDENTITY_SERVICE_DOCKER_FILE_PATH }} # src/Services/Identity/Dockerfile + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + + ### CD + # runs only for cd part when we have a push + workflow-info: + runs-on: ubuntu-latest + needs: [pre-check, call-build-test-push] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # https://docs.github.com/en/actions/learn-github-actions/expressions#operators + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + steps: + - name: create output dir + run: mkdir -p "output" + + # https://github.com/actions/download-artifact#download-all-artifacts + # download artifacts in same workflow (artifacts for before job 'call-build-test-push') + - name: Download All Artifacts For Push and workflow_dispatch + if: (github.event_name == 'workflow_dispatch' || github.event_name == 'push') + uses: actions/download-artifact@v3 + with: + path: artifacts + + # https://github.com/dawidd6/action-download-artifact + # for artifacts form another workflows we should get that artifact with github Rest call and download-artifact@v3 doesn't work + - name: Download All Artifacts For workflow_run + if: (github.event_name == 'workflow_run') + uses: dawidd6/action-download-artifact@v2 + with: + github_token: ${{secrets.GITHUB_TOKEN}} + # check the workflow run to whether it has an artifact then will get the last available artifact from the previous workflow + check_artifacts: true + workflow_conclusion: success + # previous success workflow in workflow_run after complete + workflow: ${{ github.event.workflow_run.workflow_id }} + ## uploaded artifact name, will download all artifacts if not specified + # name: artifact_name + path: artifacts + + - name: dir + if: success() + run: ls -R "${{ github.workspace }}/artifacts" + + - name: Get CD Status + if: success() + id: cd-status-step + run: | + CD_STATUS_FILE="artifacts/${{ vars.IDENTITY_SERVICE_NAME }}_cd_status_artifacts/cd_status.txt" + if [ -f $CD_STATUS_FILE ]; then + CD_STATUS=$(cat $CD_STATUS_FILE) + echo "cd-status=$CD_STATUS" >> "$GITHUB_OUTPUT" + if [ $CD_STATUS != 'true' ]; then + echo "CD status is false, so CD will be skipped" + exit 1 + fi + echo "cd-status is true, CD will be executed" + else + echo "Error: CD_STATUS_FILE not found." + exit 1 + fi + + - name: Get CI Application Version + if: success() + id: application-version-step + run: | + VERSION_FILE="artifacts/${{ vars.IDENTITY_SERVICE_NAME }}_version_artifacts/version_name.txt" + if [ -f $VERSION_FILE ]; then + VERSION=$(cat $VERSION_FILE) + echo "application-version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Error: VERSION_FILE not found." + fi + + - name: Get CI Image Name + if: success() + id: image-name-step + run: | + IMAGE_NAME_FILE="artifacts/${{ vars.IDENTITY_SERVICE_NAME }}_image_artifacts/image_name.txt" + if [ -f $IMAGE_NAME_FILE ]; then + IMAGE_NAME=$(cat $IMAGE_NAME_FILE) + echo "image-name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + else + echo "Error: IMAGE_NAME_FILE not found." + fi + + # https://askubuntu.com/questions/86849/how-to-unzip-a-zip-file-from-the-terminal + - name: unzip artifacts + if: success() + run: | + unzip "artifacts/${{ vars.IDENTITY_SERVICE_NAME }}_test_artifacts/test-results.zip" -d "output" + + - name: Ls Output Files + if: success() + run: ls -R ${{ github.workspace }}/output + + # typically release notes are published as part of the Continuous Deployment (CD) process, after the software has been built, tested, and deployed to production. + # It's best practice to publish release notes before deploying an app to the cloud. This allows users to be informed about what changes have been made and what to expect in the latest version. + call-release: + needs: [ pre-check, workflow-info ] + + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions + # https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio + ## https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository + ## https://docs.github.com/en/actions/using-workflows/reusing-workflows + permissions: + contents: write # to be able to publish a GitHub release and tags + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-publish-release-note == 'true'))) + + uses: ./.github/workflows/reusable-release.yml + secrets: inherit + + # https://docs.github.com/en/actions/deployment/about-deployments/deploying-with-github-actions + # https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment + # https://www.trywilco.com/post/wilco-ci-cd-github-heroku + # https://limeii.github.io/2022/11/deploy-to-azure-appservice-with-github-actions/ + # https://limeii.github.io/2022/11/deploy-on-multiple-environment-with-github-actions/ + # https://www.codewrecks.com/post/github/choose-environment-from-branch/ + # https://colinsalmcorner.com/musings-on-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run + # we could use workflow_run and `completed` event that triggered by CI workflow here, This event will only trigger a workflow run if the workflow file is on the default branch. + call-deploy: + needs: [pre-check, call-release] + + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + # https://stackoverflow.com/questions/69354003/github-action-job-fire-when-previous-job-skipped + # our main branches always should trigger push and CD workflow, because if we filter them based on head message, it is possible last commit exclude entire push from triggering + if: | + success() && + (github.event_name != 'pull_request' && contains(fromJson('["develop", "main", "beta", "preview", "devops/ci"]'), github.ref_name) && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.should-deploy == 'true'))) + + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#calling-a-reusable-workflow + # https://github.blog/2021-11-29-github-actions-reusable-workflows-is-generally-available/ + uses: ./.github/workflows/reusable-deploy.yml + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-inputs-and-secrets-to-a-reusable-workflow + # https://github.blog/changelog/2022-05-03-github-actions-simplify-using-secrets-with-reusable-workflows/ + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#passing-secrets-to-nested-workflows + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit + secrets: inherit + with: + environment-name: ${{ needs.pre-check.outputs.environment-name }} + release-version: ${{ needs.call-release.outputs.release-version }} + registry: ${{ vars.DOCKER_REGISTRY }} # ghcr.io + registry-endpoint: ${{ github.repository }} + service-name: ${{ vars.IDENTITY_SERVICE_NAME }} # identity-service + diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 121af3a3..8711c927 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,19 +21,20 @@ jobs: github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}' config-path: .github/multi-labeler.yml # optional, default to '.github/labeler.yml' + # - uses: release-drafter/release-drafter@v5 + # name: release-drafter auto labeler + # with: + # config-name: release-drafter.yml + # disable-releaser: true # only run auto-labeler for PRs + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # https://docs.github.com/en/actions/using-workflows/about-workflows#creating-dependent-jobs # https://docs.github.com/en/actions/using-jobs/using-jobs-in-a-workflow - name: check-conventional-commits-labels uses: docker://agilepathway/pull-request-label-checker:latest if: success() with: - any_of : feature,bug,enhancement,deprecated,security,documentation,build,ci/cd,chore,performance,formatting,dependencies + any_of : feature,bug,enhancement,refactor,deprecated,security,documentation,build,ci/cd,chore,performance,formatting,dependencies repo_token: ${{ secrets.GITHUB_TOKEN }} - # - uses: release-drafter/release-drafter@v5 - # name: release-drafter auto labeler - # with: - # config-name: release-drafter.yml - # disable-releaser: true # only run auto-labeler for PRs - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-build-test-push.yml b/.github/workflows/reusable-build-test-push.yml index 7b822b46..f365cf06 100644 --- a/.github/workflows/reusable-build-test-push.yml +++ b/.github/workflows/reusable-build-test-push.yml @@ -14,15 +14,15 @@ on: type: string coverage-exclude: description: Coverage exclude filter - default: "[BuildingBlocks.*]*%2c[ECommerce.Services.Shared]*" + default: "[BuildingBlocks.*]*%2c[FoodDelivery.Services.Shared]*" type: string unit-test-filter: description: Unit tests filter - default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~ECommerce.Services)" + default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~FoodDelivery.Services)" type: string integration-test-filter: description: Integration tests filter - default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~ECommerce.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" + default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~FoodDelivery.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" type: string project-path: description: Project path diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml index 27f858e4..aca4226c 100644 --- a/.github/workflows/reusable-build-test.yml +++ b/.github/workflows/reusable-build-test.yml @@ -14,15 +14,15 @@ on: default: '' coverage-exclude: description: Coverage exclude filter - default: "[BuildingBlocks.*]*%2c[ECommerce.Services.Shared]*" + default: "[BuildingBlocks.*]*%2c[FoodDelivery.Services.Shared]*" type: string unit-test-filter: description: Unit tests filter - default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~ECommerce.Services)" + default: "(Category=Unit&FullyQualifiedName~UnitTests&FullyQualifiedName~FoodDelivery.Services)" type: string integration-test-filter: description: Integration tests filter - default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~ECommerce.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" + default: "(Category=Integration&FullyQualifiedName~IntegrationTests&FullyQualifiedName~FoodDelivery.Services)|(Category=EndToEnd&FullyQualifiedName~EndToEndTests)" type: string project-path: description: Project path diff --git a/.gitignore b/.gitignore index 734f661f..f699e6fc 100644 --- a/.gitignore +++ b/.gitignore @@ -204,10 +204,8 @@ csx/ ecf/ rcf/ -# Windows ECommerce app package directories and files AppPackages/ BundleArtifacts/ -Package.ECommerceAssociation.xml _pkginfo.txt *.appx *.appxbundle @@ -370,3 +368,11 @@ coverage.info # Built Visual Studio Code Extensions *.vsix + +# exclude sensitive env files +.env +.env.dev +.denv.prod +.env.catalogs +.env.customers +.env.identity \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..82dd5963 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,123 @@ +# https://github.com/gitpod-samples/template-dotnet-core-cli-csharp +# https://www.gitpod.io/docs/introduction/languages/dotnet +# https://github.com/gitpod-samples/template-docker-compose +# https://www.gitpod.io/docs/references/gitpod-yml +# https://www.gitpod.io/docs/configure +# https://www.gitpod.io/docs/configure/workspaces/ports +# https://www.gitpod.io/docs/configure/projects/prebuilds + +image: + file: .gitpod.Dockerfile + +vscode: + extensions: + - streetsidesoftware.code-spell-checker + - ms-dotnettools.csdevkit + - editorconfig.editorconfig + - vivaxy.vscode-conventional-commits + - humao.rest-client + - ms-azuretools.vscode-docker + - donjayamanne.githistory + - mutantdino.resourcemonitor + - pkief.material-icon-theme + - emmanuelbeziat.vscode-great-icons + - tabnine.tabnine-vscode + - ms-vscode.vs-keybindings + - streetsidesoftware.code-spell-checker + - me-dutour-mathieu.vscode-github-actions + +# https://www.gitpod.io/docs/configure/workspaces/tasks#execution-order +tasks: + - name: Install dev cert + #https://learn.microsoft.com/en-us/aspnet/core/security/docker-https?view=aspnetcore-7.0#macos-or-linux + #https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl + #`dotnet dev-certs https --trust` is only supported on macOS and Windows, but we don't get exception because of none trusted cert in our application, if we don't use this cert we get exception. It is likely that we need to trust the certificate in `your browser` and allow to process for none trusted cert. + init: | + dotnet dev-certs https -ep ${HOME}/.aspnet/https/aspnetapp.pfx -p 123456 + dotnet dev-certs https --trust + + - name: Husky Tools Setup + # https://www.gitpod.io/docs/configure/projects + # https://www.gitpod.io/docs/configure/projects/prebuilds + # https://www.gitpod.io/docs/configure/workspaces/tasks + # If prebuild is set up on repository with linking repository to a project, when there is a commit to project `init` commands will execute in prebuild process and its workspace will share with all new created workspace for this project, if we don't have a project when we create a new workspace `init` commands will execute + init: | + npm install + npm run prepare + + - name: Restore and Build dotnet + # https://www.gitpod.io/docs/configure/projects + # https://www.gitpod.io/docs/configure/projects/prebuilds + # https://www.gitpod.io/docs/configure/workspaces/tasks + # If prebuild is set up on repository with linking repository to a project, when there is a commit to project `init` commands will execute in prebuild process and its workspace will share with all new created workspace for this project, if we don't have a project when we create a new workspace `init` commands will execute + init: | + gp sync-await docker-bundle # wait for the bellow 'init' to finish + dotnet restore + dotnet build + gp sync-done build-bundle + + # each task creates a new terminal window + # https://www.gitpod.io/docs/configure/workspaces/tasks#wait-for-commands-to-complete + - name: Init Docker-Compose + # https://www.gitpod.io/docs/configure/projects/prebuilds + # We load docker on pre-build for increasing speed + init: | + docker-compose -f ./deployments/docker-compose/docker-compose.infrastructure.yaml pull + # https://www.gitpod.io/docs/configure/workspaces/tasks#wait-for-commands-to-complete + # https://github.com/gitpod-io/openvscode-server/discussions/373 + gp sync-done docker-bundle + command: | + docker-compose -f ./deployments/docker-compose/docker-compose.infrastructure.yaml up -d + +# - name: Run Catalogs in Watch Mode +# # https://www.gitpod.io/docs/configure/workspaces/tasks#wait-for-commands-to-complete +# # https://github.com/gitpod-io/openvscode-server/discussions/373 +# init: | +# gp sync-await docker-bundle # wait for the above 'init' to finish +# gp sync-await build-bundle # wait for the above 'init' to finish +# # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-run#options +# command: | +# cd src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api +# # hot reload doesn't work correctly with --no-build and --no-restore and after each change will restart the app with hot reload app should not be restarted +# dotnet watch -lp Catalogs.Api.Http +# +# - name: Run Identity in Watch Mode +# init: | +# gp sync-await docker-bundle # wait for the above 'init' to finish +# gp sync-await build-bundle # wait for the above 'init' to finish +# command: | +# cd src/Services/Identity/FoodDelivery.Services.Identity.Api +# dotnet watch -lp Identity.Api.Http +# +# - name: Run Customers in Watch Mode +# init: | +# gp sync-await docker-bundle # wait for the above 'init' to finish +# gp sync-await build-bundle # wait for the above 'init' to finish +# command: | +# cd src/Services/Customers/FoodDelivery.Services.Customers.Api +# # hot reload doesn't work correctly with --no-build and --no-restore and after each change will restart the app with hot reload app should not be restarted +# dotnet watch -lp Customers.Api.Http + +# https://www.gitpod.io/docs/configure/projects/prebuilds#github-specific-configuration +github: + prebuilds: + # enable for the default branch (defaults to true) + master: true + # enable for all branches in this repo (defaults to false) + branches: true + # enable for pull requests coming from this repo (defaults to true) + pullRequests: true + # enable for pull requests coming from forks (defaults to false) + pullRequestsFromForks: false + # add a check to pull requests (defaults to true) + addCheck: true + # add a "Review in Gitpod" button as a comment to pull requests (defaults to false) + addComment: false + # add a "Review in Gitpod" button to the pull request's description (defaults to false) + addBadge: false + +#https://www.gitpod.io/docs/configure/workspaces/ports#exposing-ports +#https://github.com/gitpod-io/gitpod/issues/1867 +ports: + - port: 1000-9000 + onOpen: ignore diff --git a/.husky/pre-commit b/.husky/pre-commit index 20416d67..001bdf65 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -dotnet csharpier . +dotnet csharpier . && git add -A . diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..d05967f4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,854 @@ +{ + "configurations": [ + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.youtube.com/watch?v=k0hpant9wXo + { + "name": "Launch: Catalogs Service", + "type": "coreclr", + "request": "launch", + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#launchsettingsjson-support + "launchSettingsProfile": "Catalogs.Api.Http", + "preLaunchTask": "build: catalogs", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}", + "windows": { + "command": "cmd.exe", + "args": "/C start ${auto-detect-url}" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Catalogs.Api.dll", + "args": [], + // should be set expelcitly for root of our api project + "cwd": "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api", + "console": "externalTerminal", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + //https://github.com/Trottero/dotnet-watch-attach + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "type": "dotnetwatchattach", + "request": "launch", + "name": "Watch: Catalogs Service", + "task": "watch: catalogs", // Label of watch task in tasks.json + // just the name of exe file not `full path` this exe name will use to attach to exe name process, and program should be `exe` file because processes to attach are `exe` when we use `attach` request type + // here we attach to existing running exe project, that already is run by watch command + "program": "FoodDelivery.Services.Catalogs.Api.exe" + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + // here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `catalogs-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + // for see data inner container `docker exec -it catalogs-debug bash` + { + "name": "Docker Launch: Catalogs Service", + "type": "coreclr", + "request": "launch", + "console": "externalTerminal", + "preLaunchTask": "docker-run-debug: catalogs", + "postDebugTask": "docker-remove-debug: catalogs", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container after that coreclr will launch our app with program attribute on container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "catalogs-debug" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "/app/FoodDelivery.Services.Catalogs.Api.dll", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). + //for see data inner container `docker exec -it catalogs-base bash` + { + "name": "Docker Launch Base: Catalogs Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "docker-run-base: catalogs", + "postDebugTask": "docker-remove-base: catalogs", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "catalogs-base" + ], + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Catalogs.Api.dll", + //change working dicrectory inner container and inner /app working directory + "cwd": "src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/app": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //for see data inner container `docker exec -it catalogs-dev` + { + "name": "Docker Attach: Catalogs Service", + "type": "coreclr", + // we will attach to a process from pop-up (here we should choose `FoodDelivery.Services.Catalogs`), inner `catalogs-dev` container when we connected through `pipeTransport` to the container + "request": "attach", + // connecting `vsdbg debugger` to `docker container` with using vsdbg inner container and ssh to our `local source code` + // `pipeTransport` will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "catalogs-dev" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + }, + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.youtube.com/watch?v=k0hpant9wXo + { + "name": "Launch: Customers Service", + "type": "coreclr", + "request": "launch", + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#launchsettingsjson-support + "launchSettingsProfile": "Customers.Api.Http", + "preLaunchTask": "build: customers", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}", + "windows": { + "command": "cmd.exe", + "args": "/C start ${auto-detect-url}" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Customers.Api.dll", + "args": [], + // should be set expelcitly for root of our api project + "cwd": "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.Api", + "console": "externalTerminal", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + //https://github.com/Trottero/dotnet-watch-attach + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "type": "dotnetwatchattach", + "request": "launch", + "name": "Watch: Customers Service", + "task": "watch: customers", // Label of watch task in tasks.json + // just the name of exe file not `full path`, this exe name will use to attach to exe name process, and program should be `exe` file because processes to attach are `exe` when we use `attach` request type through popup + // here we attach to existing running exe project, that already is run by watch command + "program": "FoodDelivery.Services.Customers.Api.exe" + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + // here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `customers-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + // for see data inner container `docker exec -it customers-debug bash` + { + "name": "Docker Launch: Customers Service", + "type": "coreclr", + "request": "launch", + "console": "externalTerminal", + "preLaunchTask": "docker-run-debug: customers", + "postDebugTask": "docker-remove-debug: customers", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container after that coreclr will launch our app with program attribute on container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "customers-debug" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "/app/FoodDelivery.Services.Customers.Api.dll", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). + //for see data inner container `docker exec -it customers-base bash` + { + "name": "Docker Launch Base: Customers Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "docker-run-base: customers", + "postDebugTask": "docker-remove-base: customers", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "customers-base" + ], + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Customers.Api.dll", + //change working dicrectory inner container and inner /app working directory + "cwd": "src/Services/Customers/FoodDelivery.Services.Customers.Api", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/app": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //for see data inner container `docker exec -it customers-dev` + { + "name": "Docker Attach: Customers Service", + "type": "coreclr", + // we will attach to a process from pop-up (here we should choose `FoodDelivery.Services.Customers`), inner `customers-dev` container when we connected through `pipeTransport` to the container + "request": "attach", + // connecting `vsdbg debugger` to `docker container` with using vsdbg inner container and ssh to our `local source code` + // `pipeTransport` will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "customers-dev" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + }, + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.youtube.com/watch?v=k0hpant9wXo + { + "name": "Launch: Identity Service", + "type": "coreclr", + "request": "launch", + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#launchsettingsjson-support + "launchSettingsProfile": "Identity.Api.Http", + "preLaunchTask": "build: identity", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}", + "windows": { + "command": "cmd.exe", + "args": "/C start ${auto-detect-url}" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Identity.Api.dll", + "args": [], + // should be set expelcitly for root of our api project + "cwd": "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.Api", + "console": "externalTerminal", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + //https://github.com/Trottero/dotnet-watch-attach + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "type": "dotnetwatchattach", + "request": "launch", + "name": "Watch: Identity Service", + "task": "watch: identity", // Label of watch task in tasks.json + // just the name of exe file not `full path`, this exe name will use to attach to exe name process, and program should be `exe` file because processes to attach are `exe` when we use `attach` request type through popup + // here we attach to existing running exe project, that already is run by watch command + "program": "FoodDelivery.Services.Identity.Api.exe" + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + // here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + // for see data inner container `docker exec -it identity-debug bash` + { + "name": "Docker Launch: Identity Service", + "type": "coreclr", + "request": "launch", + "console": "externalTerminal", + "preLaunchTask": "docker-run-debug: identity", + "postDebugTask": "docker-remove-debug: identity", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container after that coreclr will launch our app with program attribute on container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "identity-debug" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "/app/FoodDelivery.Services.Identity.Api.dll", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). + //for see data inner container `docker exec -it identity-base bash` + { + "name": "Docker Launch Base: Identity Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "docker-run-base: identity", + "postDebugTask": "docker-remove-base: identity", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "identity-base" + ], + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Identity.Api.dll", + //change working dicrectory inner container and inner /app working directory + "cwd": "src/Services/Identity/FoodDelivery.Services.Identity.Api", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/app": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //for see data inner container `docker exec -it identity-dev` + { + "name": "Docker Attach: Identity Service", + "type": "coreclr", + // we will attach to a process from pop-up (here we should choose `FoodDelivery.Services.Identity`), inner `identity-dev` container when we connected through `pipeTransport` to the container + "request": "attach", + // connecting `vsdbg debugger` to `docker container` with using vsdbg inner container and ssh to our `local source code` + // `pipeTransport` will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "identity-dev" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + }, + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.youtube.com/watch?v=k0hpant9wXo + { + "name": "Launch: Gateway Service", + "type": "coreclr", + "request": "launch", + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#launchsettingsjson-support + "launchSettingsProfile": "ApiGateway.Http", + "preLaunchTask": "build: gateway", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}", + "windows": { + "command": "cmd.exe", + "args": "/C start ${auto-detect-url}" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.ApiGateway.dll", + "args": [], + // should be set expelcitly for root of our api project + "cwd": "${workspaceFolder}/src/ApiGateway/FoodDelivery.ApiGateway", + "console": "externalTerminal", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + //https://github.com/Trottero/dotnet-watch-attach + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "type": "dotnetwatchattach", + "request": "launch", + "name": "Watch: Gateway Service", + "task": "watch: gateway", // Label of watch task in tasks.json + // just the name of exe file not `full path`, this exe name will use to attach to exe name process, and program should be `exe` file because processes to attach are `exe` when we use `attach` request type through popup + // here we attach to existing running exe project, that already is run by watch command + "program": "FoodDelivery.ApiGateway.exe" + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + // here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `gateway-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + // for see data inner container `docker exec -it gateway-debug bash` + { + "name": "Docker Launch: Gateway Service", + "type": "coreclr", + "request": "launch", + "console": "externalTerminal", + "preLaunchTask": "docker-run-debug: gateway", + "postDebugTask": "docker-remove-debug: gateway", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container after that coreclr will launch our app with program attribute on container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "gateway-debug" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "/app/FoodDelivery.ApiGateway.dll", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). + //for see data inner container `docker exec -it gateway-base bash` + { + "name": "Docker Launch Base: Gateway Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "docker-run-base: gateway", + "postDebugTask": "docker-remove-base: gateway", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "gateway-base" + ], + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.ApiGateway.dll", + //change working dicrectory inner container and inner /app working directory + "cwd": "src/ApiGateway/FoodDelivery.ApiGateway", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/app": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //for see data inner container `docker exec -it gateway-dev` + { + "name": "Docker Attach: Gateway Service", + "type": "coreclr", + // we will attach to a process from pop-up (here we should choose `FoodDelivery.ApiGateway.exe`), inner `gateway-dev` container when we connected through `pipeTransport` to the container + "request": "attach", + // connecting `vsdbg debugger` to `docker container` with using vsdbg inner container and ssh to our `local source code` + // `pipeTransport` will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "gateway-dev" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + }, + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.youtube.com/watch?v=k0hpant9wXo + { + "name": "Launch: Orders Service", + "type": "coreclr", + "request": "launch", + //https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#launchsettingsjson-support + "launchSettingsProfile": "Orders.Api.Http", + "preLaunchTask": "build: orders", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}", + "windows": { + "command": "cmd.exe", + "args": "/C start ${auto-detect-url}" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Orders.Api.dll", + "args": [], + // should be set expelcitly for root of our api project + "cwd": "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.Api", + "console": "externalTerminal", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + } + }, + //https://github.com/Trottero/dotnet-watch-attach + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "type": "dotnetwatchattach", + "request": "launch", + "name": "Watch: Orders Service", + "task": "watch: orders", // Label of watch task in tasks.json + // just the name of exe file not `full path`, this exe name will use to attach to exe name process, and program should be `exe` file because processes to attach are `exe` when we use `attach` request type through popup + // here we attach to existing running exe project, that already is run by watch command + "program": "FoodDelivery.Services.Orders.Api.exe" + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + // here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `orders-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + // for see data inner container `docker exec -it orders-debug bash` + { + "name": "Docker Launch: Orders Service", + "type": "coreclr", + "request": "launch", + "console": "externalTerminal", + "preLaunchTask": "docker-run-debug: orders", + "postDebugTask": "docker-remove-debug: orders", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container after that coreclr will launch our app with program attribute on container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "orders-debug" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "/app/FoodDelivery.Services.Orders.Api.dll", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). + //for see data inner container `docker exec -it orders-base bash` + { + "name": "Docker Launch Base: Orders Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "docker-run-base: orders", + "postDebugTask": "docker-remove-base: orders", + // connecting debugger to docker container with using vsdbg inner container and ssh to our local source code + // it will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "orders-base" + ], + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + //our container with base stage and without entrypoint is run on `app/` working directory + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set `bin` directory and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "program": "bin/Debug/net7.0/FoodDelivery.Services.Orders.Api.dll", + //change working dicrectory inner container and inner /app working directory + "cwd": "src/Services/Orders/FoodDelivery.Services.Orders.Api", + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/app": "${workspaceRoot}" + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet#options-for-running-an-application + //https://github.com/microsoft/vscode-docker/issues/3597 + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + "args": "--additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages --additionalProbingPath /root/.nuget/fallbackpackages", + }, + //https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://www.aaron-powell.com/posts/2019-04-04-debugging-dotnet-in-docker-with-vscode/ + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes + //for see data inner container `docker exec -it orders-dev` + { + "name": "Docker Attach: Orders Service", + "type": "coreclr", + // we will attach to a process from pop-up (here we should choose `FoodDelivery.Services.Orders`), inner `orders-dev` container when we connected through `pipeTransport` to the container + "request": "attach", + // connecting `vsdbg debugger` to `docker container` with using vsdbg inner container and ssh to our `local source code` + // `pipeTransport` will run `exec -i /vsdbg/vsdbg ` inner container + "pipeTransport": { + "pipeArgs": [ + "exec", + "-i", + "orders-dev" + ], + "pipeProgram": "docker", + "quoteArgs": false, + "debuggerPath": "/vsdbg/vsdbg" + }, + "justMyCode": false, + //To debug programs built on computers other than the Visual Studio Code computer (the dll and its pdbs are on the container), Visual Studio Code needs to be hold how to map file paths (SourceMap attribute), actually it maps path inner container pdbs to local path. This rule tells the debugger to change any `file paths` in `container pdbs` from `source` in SourceMap (`container pdb path`) to `target` in SourceMap (our `local code`) and replace it with the `local code` path. paths in the containers pdbs built and get from path during `dotnet build` in dockerfile. + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#configuring-ssh-attach-with-launchjson + //https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#building-and-deploying-the-application-and-pdbs + "sourceFileMap": { + "/": "${workspaceRoot}" + }, + }, + { + "name": "Attach: .Net Core", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d1fa75de --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "git.autofetch": true, + "files.autoSave": "onFocusChange", + "editor.formatOnSave": true, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "explorer.autoReveal": true, + "resmon.show.cpufreq": false, + "dotnet.defaultSolution": "food-delivery.sln", + "dotnet.server.startTimeout": 60000, + "omnisharp.projectLoadTimeout": 60, + "workbench.colorTheme": "Visual Studio Light", + "workbench.iconTheme": "material-icon-theme", + "editor.minimap.enabled": false, + "editor.fontFamily": "'MesloLGM Nerd Font', 'Droid Sans Mono', 'monospace', 'Droid Sans Fallback', 'Consolas'", + "editor.fontSize": 14, + "explorer.confirmDelete": false, + "terminal.integrated.defaultProfile.windows": "PowerShell", + "terminal.integrated.defaultProfile.linux": "zsh", + "powershell.cwd": "~", + "terminal.external.windowsExec": "%LOCALAPPDATA%\\Microsoft\\WindowsApps\\pwsh.exe" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..a44daac8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,1024 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + //https://code.visualstudio.com/docs/editor/tasks + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://github.com/thehaseebahmed/vscode-dotnet-docker-debug + //https://www.youtube.com/watch?v=k0hpant9wXo + "tasks": [ + { + "label": "build: catalogs", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://stackoverflow.com/questions/59830506/how-to-setup-an-auto-watch-run-for-net-core-3-1-projects-using-visual-studio-co + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "label": "watch: catalogs", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "watch", + "--project", + "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api" + }, + "linux": { + "options": { + "env": { + // The FileSystemWatcher used by default wasnt working for me on linux, so I switched to the polling watcher. + "DOTNET_USE_POLLING_FILE_WATCHER": "true" + } + } + }, + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + }, + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://docs.docker.com/engine/reference/commandline/build/#target + // this task only build target stage here `final`, and we will change the entrypoint in 'ducker run' (we change entrypoint only in debug mode) with --entrypoint because we don't want our contaoner runs we will run our cotainer in debug mode with launching app from docker container remotly + { + "label": "docker-build-dev: catalogs", + "command": "docker build --target final -f ${workspaceFolder}/src/Services/Catalogs/dev.Dockerfile --tag catalogs:dev ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-build-prod: catalogs", + "command": "docker build --target final -f ${workspaceFolder}/src/Services/Catalogs/Dockerfile --tag catalogs:prod ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + //https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container + //https://laurentkempe.com/2022/11/14/dotnet-7-sdk-built-in-container-support-and-ubuntu-chiseled/ + //https://www.mytechramblings.com/posts/trying-out-the-built-in-container-support-for-the-dotnet-7-sdk/ + { + "label": "docker-dotnet-publsih: catalogs", + "command": "dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api" + }, + "problemMatcher": [] + }, + { + "label": "docker-build-base: catalogs", + "command": "docker build --target base -f ${workspaceFolder}/src/Services/Catalogs/dev.Dockerfile --tag catalogs:base ${workspaceFolder}", + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it catalogs-debug` + { + "label": "docker-run-debug: catalogs", + "command": "${workspaceFolder}/scripts/docker/debug-run.sh catalogs-debug catalogs:dev 4000 4001 ${workspaceFolder}/src/Services/Catalogs/.env", + "dependsOn": [ + "docker-build-dev: catalogs" + ], + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it catalogs-dev bash` + { + "label": "docker-run-dev: catalogs", + "command": "${workspaceFolder}/scripts/docker/dev-run.sh catalogs-dev catalogs:dev 4000 4001 ${workspaceFolder}/src/Services/Catalogs/.env", + "dependsOn": [ + "docker-build-dev: catalogs" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-prod: catalogs", + "command": "${workspaceFolder}/scripts/docker/prod-run.sh catalogs-prod catalogs:prod 4000 4001 ${workspaceFolder}/src/Services/Catalogs/.env", + "dependsOn": [ + "docker-build-prod: catalogs" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-base: catalogs", + "command": "${workspaceFolder}/scripts/docker/base-run.sh catalogs-base catalogs:base 4000 4001 ${workspaceFolder}/src/Services/Catalogs/.env ${workspaceRoot}", + "dependsOn": [ + "docker-build-base: catalogs" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-base: catalogs", + "command": "docker container rm catalogs-base --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-debug: catalogs", + "command": "docker container rm catalogs-debug --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "unit test: catalogs", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/FoodDelivery.Services.Catalogs.UnitTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "integrtion test: catalogs", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/FoodDelivery.Services.Catalogs.IntegrationTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "end-to-end test: catalogs", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/FoodDelivery.Services.Catalogs.EndToEndTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "build: customers", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + "group": { + "kind": "build" + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://stackoverflow.com/questions/59830506/how-to-setup-an-auto-watch-run-for-net-core-3-1-projects-using-visual-studio-co + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "label": "watch: customers", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "watch", + "--project", + "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.Api" + }, + "linux": { + "options": { + "env": { + // The FileSystemWatcher used by default wasnt working for me on linux, so I switched to the polling watcher. + "DOTNET_USE_POLLING_FILE_WATCHER": "true" + } + } + }, + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + }, + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://docs.docker.com/engine/reference/commandline/build/#target + // this task only build target stage here `final`, and we will change the entrypoint in 'ducker run' (we change entrypoint only in debug mode) with --entrypoint because we don't want our contaoner runs we will run our cotainer in debug mode with launching app from docker container remotly + { + "label": "docker-build-dev: customers", + "command": "docker build --target final -f ${workspaceFolder}/src/Services/Customers/dev.Dockerfile --tag customers:dev ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + //https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container + //https://laurentkempe.com/2022/11/14/dotnet-7-sdk-built-in-container-support-and-ubuntu-chiseled/ + //https://www.mytechramblings.com/posts/trying-out-the-built-in-container-support-for-the-dotnet-7-sdk/ + { + "label": "docker-dotnet-publsih: customers", + "command": "dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.Api" + }, + "problemMatcher": [] + }, + { + "label": "docker-build-base: customers", + "command": "docker build --target base -f ${workspaceFolder}/src/Services/Customers/dev.Dockerfile --tag customers:base ${workspaceFolder}", + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it customers-debug` + { + "label": "docker-run-debug: customers", + "command": "${workspaceFolder}/scripts/docker/debug-run.sh customers-debug customers:dev 8000 8001 ${workspaceFolder}/src/Services/Customers/.env", + "dependsOn": [ + "docker-build-dev: customers" + ], + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it customers-dev bash` + { + "label": "docker-run-dev: customers", + "command": "${workspaceFolder}/scripts/docker/dev-run.sh customers-dev customers:dev 8000 8001 ${workspaceFolder}/src/Services/Customers/.env", + "dependsOn": [ + "docker-build-dev: customers" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-base: customers", + "command": "${workspaceFolder}/scripts/docker/base-run.sh customers-base customers:base 8000 8001 ${workspaceFolder}/src/Services/Customers/.env ${workspaceRoot}", + "dependsOn": [ + "docker-build-base: customers" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-base: customers", + "command": "docker container rm customers-base --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-debug: customers", + "command": "docker container rm customers-debug --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "unit test: customers", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/FoodDelivery.Services.Customers.UnitTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "integrtion test: customers", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/FoodDelivery.Services.Customers.IntegrationTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "end-to-end test: customers", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/FoodDelivery.Services.Customers.EndToEndTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "build: identity", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + "group": { + "kind": "build" + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://stackoverflow.com/questions/59830506/how-to-setup-an-auto-watch-run-for-net-core-3-1-projects-using-visual-studio-co + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "label": "watch: identity", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "watch", + "--project", + "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.Api" + }, + "linux": { + "options": { + "env": { + // The FileSystemWatcher used by default wasnt working for me on linux, so I switched to the polling watcher. + "DOTNET_USE_POLLING_FILE_WATCHER": "true" + } + } + }, + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + }, + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://docs.docker.com/engine/reference/commandline/build/#target + // this task only build target stage here `final`, and we will change the entrypoint in 'ducker run' (we change entrypoint only in debug mode) with --entrypoint because we don't want our contaoner runs we will run our cotainer in debug mode with launching app from docker container remotly + { + "label": "docker-build-dev: identity", + "command": "docker build --target final -f ${workspaceFolder}/src/Services/Identity/dev.Dockerfile --tag identity:dev ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + //https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container + //https://laurentkempe.com/2022/11/14/dotnet-7-sdk-built-in-container-support-and-ubuntu-chiseled/ + //https://www.mytechramblings.com/posts/trying-out-the-built-in-container-support-for-the-dotnet-7-sdk/ + { + "label": "docker-dotnet-publsih: identity", + "command": "dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release", + "type": "shell", + "dependsOn": [ + "build: identity" + ], + "options": { + "cwd": "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.Api" + }, + "problemMatcher": [] + }, + { + "label": "docker-build-base: identity", + "command": "docker build --target base -f ${workspaceFolder}/src/Services/Identity/dev.Dockerfile --tag identity:base ${workspaceFolder}", + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it identity-debug` + { + "label": "docker-run-debug: identity", + "command": "${workspaceFolder}/scripts/docker/debug-run.sh identity-debug identity:dev 7000 7001 ${workspaceFolder}/src/Services/Identity/.env", + "dependsOn": [ + "docker-build-dev: identity" + ], + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it identity-dev bash` + { + "label": "docker-run-dev: identity", + "command": "${workspaceFolder}/scripts/docker/dev-run.sh identity-dev identity:dev 7000 7001 ${workspaceFolder}/src/Services/Identity/.env", + "dependsOn": [ + "docker-build-dev: identity" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-base: identity", + "command": "${workspaceFolder}/scripts/docker/base-run.sh identity-base identity:base 7000 7001 ${workspaceFolder}/src/Services/Identity/.env ${workspaceRoot}", + "dependsOn": [ + "docker-build-base: identity" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-base: identity", + "command": "docker container rm identity-base --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-debug: identity", + "command": "docker container rm identity-debug --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "unit test: identity", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/FoodDelivery.Services.Identity.UnitTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "integrtion test: identity", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/FoodDelivery.Services.Identity.IntegrationTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "end-to-end test: identity", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/FoodDelivery.Services.Identity.EndToEndTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "build: orders", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + "group": { + "kind": "build" + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://stackoverflow.com/questions/59830506/how-to-setup-an-auto-watch-run-for-net-core-3-1-projects-using-visual-studio-co + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "label": "watch: orders", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "watch", + "--project", + "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.Api" + }, + "linux": { + "options": { + "env": { + // The FileSystemWatcher used by default wasnt working for me on linux, so I switched to the polling watcher. + "DOTNET_USE_POLLING_FILE_WATCHER": "true" + } + } + }, + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + }, + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://docs.docker.com/engine/reference/commandline/build/#target + // this task only build target stage here `final`, and we will change the entrypoint in 'ducker run' (we change entrypoint only in debug mode) with --entrypoint because we don't want our contaoner runs we will run our cotainer in debug mode with launching app from docker container remotly + { + "label": "docker-build-dev: orders", + "command": "docker build --target final -f ${workspaceFolder}/src/Services/Orders/dev.Dockerfile --tag orders:dev ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + //https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container + //https://laurentkempe.com/2022/11/14/dotnet-7-sdk-built-in-container-support-and-ubuntu-chiseled/ + //https://www.mytechramblings.com/posts/trying-out-the-built-in-container-support-for-the-dotnet-7-sdk/ + { + "label": "docker-dotnet-publsih: orders", + "command": "dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.Api" + }, + "problemMatcher": [] + }, + { + "label": "docker-build-base: orders", + "command": "docker build --target base -f ${workspaceFolder}/src/Services/Orders/dev.Dockerfile --tag orders:base ${workspaceFolder}", + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it orders-debug` + { + "label": "docker-run-debug: orders", + "command": "${workspaceFolder}/scripts/docker/debug-run.sh orders-debug orders:dev 9000 9001 ${workspaceFolder}/src/Services/Orders/.env", + "dependsOn": [ + "docker-build-dev: orders" + ], + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it orders-dev bash` + { + "label": "docker-run-dev: orders", + "command": "${workspaceFolder}/scripts/docker/dev-run.sh orders-dev orders:dev 9000 9001 ${workspaceFolder}/src/Services/Orders/.env", + "dependsOn": [ + "docker-build-dev: orders" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-base: orders", + "command": "${workspaceFolder}/scripts/docker/base-run.sh orders-base orders:base 9000 9001 ${workspaceFolder}/src/Services/Orders/.env ${workspaceRoot}", + "dependsOn": [ + "docker-build-base: orders" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-base: orders", + "command": "docker container rm orders-base --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-debug: orders", + "command": "docker container rm orders-debug --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "unit test: orders", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/tests/Services/Orders/FoodDelivery.Services.Orders.UnitTests/FoodDelivery.Services.Orders.UnitTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "integrtion test: orders", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.IntegrationTests/FoodDelivery.Services.Orders.IntegrationTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "end-to-end test: orders", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "test", + "${workspaceFolder}/src/Services/Orders/FoodDelivery.Services.Orders.EndToEndTests/FoodDelivery.Services.Orders.EndToEndTests.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "build: gateway", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj" + ], + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://stackoverflow.com/questions/59830506/how-to-setup-an-auto-watch-run-for-net-core-3-1-projects-using-visual-studio-co + //https://jasonwatmore.com/post/2021/06/24/vs-code-net-debug-a-net-web-app-in-visual-studio-code + //https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch + { + "label": "watch: gateway", + "command": "dotnet", + "type": "process", + //when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in `cwd`, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` based on `api project` + "args": [ + "watch", + "--project", + "${workspaceFolder}/src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/src/ApiGateway/FoodDelivery.ApiGateway" + }, + "linux": { + "options": { + "env": { + // The FileSystemWatcher used by default wasnt working for me on linux, so I switched to the polling watcher. + "DOTNET_USE_POLLING_FILE_WATCHER": "true" + } + } + }, + "problemMatcher": "$msCompile", + //https://code.visualstudio.com/docs/editor/tasks-appendix + //https://code.visualstudio.com/docs/editor/tasks#_custom-tasks + "group": { + "kind": "build" + }, + }, + //https://github.com/microsoft/vscode-docker/issues/3831 + //https://docs.docker.com/engine/reference/commandline/build/#target + // this task only build target stage here `final`, and we will change the entrypoint in 'ducker run' (we change entrypoint only in debug mode) with --entrypoint because we don't want our contaoner runs we will run our cotainer in debug mode with launching app from docker container remotly + { + "label": "docker-build-dev: gateway", + "command": "docker build --target final -f ${workspaceFolder}/src/ApiGateway/dev.Dockerfile --tag gateway:dev ${workspaceFolder}", //--no-cache + "type": "shell", + "problemMatcher": [] + }, + //https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container + //https://laurentkempe.com/2022/11/14/dotnet-7-sdk-built-in-container-support-and-ubuntu-chiseled/ + //https://www.mytechramblings.com/posts/trying-out-the-built-in-container-support-for-the-dotnet-7-sdk/ + { + "label": "docker-dotnet-publsih: gateway", + "command": "dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/src/ApiGateway/FoodDelivery.ApiGateway" + }, + "problemMatcher": [] + }, + { + "label": "docker-build-base: gateway", + "command": "docker build --target base -f ${workspaceFolder}/src/ApiGateway/dev.Dockerfile --tag gateway:base ${workspaceFolder}", + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it gateway-debug` + { + "label": "docker-run-debug: gateway", + "command": "${workspaceFolder}/scripts/docker/debug-run.sh gateway-debug gateway:dev 3000 3001 ${workspaceFolder}/src/ApiGateway/.env", + "dependsOn": [ + "docker-build-dev: gateway" + ], + "type": "shell", + "problemMatcher": [] + }, + // for see data inner container `docker exec -it gateway-dev bash` + { + "label": "docker-run-dev: gateway", + "command": "${workspaceFolder}/scripts/docker/dev-run.sh gateway-dev gateway:dev 3000 3001 ${workspaceFolder}/src/ApiGateway/.env", + "dependsOn": [ + "docker-build-dev: gateway" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-run-base: gateway", + "command": "${workspaceFolder}/scripts/docker/base-run.sh gateway-base gateway:base 3000 3001 ${workspaceFolder}/src/ApiGateway/.env ${workspaceRoot}", + "dependsOn": [ + "docker-build-base: gateway" + ], + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-base: gateway", + "command": "docker container rm gateway-base --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-remove-debug: gateway", + "command": "docker container rm gateway-debug --force", + "type": "shell", + "problemMatcher": [] + }, + { + "label": "docker-compose-build: infrastructures", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.infrastructure.yaml build", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://docs.docker.com/engine/reference/commandline/compose_up/ + //https://docs.docker.com/engine/reference/commandline/compose_start/ + { + "label": "docker-compose-up: infrastructures", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.infrastructure.yaml up -d", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://code.visualstudio.com/docs/containers/reference#_docker-compose-task + //https://docs.docker.com/engine/reference/commandline/compose_down/ + //https://docs.docker.com/engine/reference/commandline/compose_stop/ + { + "label": "docker-compose-down: infrastructures", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.infrastructure.yaml down", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + { + "label": "docker-compose-build: debug services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.debug.yaml build", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.htmlk + //https://docs.docker.com/engine/reference/commandline/compose_up/ + //https://docs.docker.com/engine/reference/commandline/compose_start/ + { + "label": "docker-compose-up: debug services", + "dependsOn": [ + "docker-compose: up infrastructures" + ], + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + // to remove containers we use `docker-compose down` + //https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.debug.yaml --env-file ${workspaceFolder}/deployments/docker-compose/.env --env-file ${workspaceFolder}/deployments/docker-compose/.env.dev up -d", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://docs.docker.com/engine/reference/commandline/compose_down/ + //https://docs.docker.com/engine/reference/commandline/compose_stop/ + { + "label": "docker-compose-down: debug services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.debug.yaml down", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + { + "label": "docker-compose-build: dev services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.dev.yaml build", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.htmlk + //https://docs.docker.com/engine/reference/commandline/compose_up/ + //https://docs.docker.com/engine/reference/commandline/compose_start/ + { + "label": "docker-compose-up: dev services", + "dependsOn": [ + "docker-compose: up infrastructures" + ], + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + // to remove containers we use `docker-compose down` + //https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.dev.yaml --env-file ${workspaceFolder}/deployments/docker-compose/.env --env-file ${workspaceFolder}/deployments/docker-compose/.env.dev up", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + //https://docs.docker.com/engine/reference/commandline/compose_down/ + //https://docs.docker.com/engine/reference/commandline/compose_stop/ + { + "label": "docker-compose-down: dev services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.dev.yaml down", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://docs.docker.com/engine/reference/commandline/compose_up/ + //https://docs.docker.com/engine/reference/commandline/compose_start/ + { + "label": "docker-compose-up: prod services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + // to remove containers we use `docker-compose down` + //https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml --env-file ${workspaceFolder}/deployments/docker-compose/.env --env-file ${workspaceFolder}/deployments/docker-compose/.env.prod up", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + //https://docs.docker.com/engine/reference/commandline/compose_down/ + //https://docs.docker.com/engine/reference/commandline/compose_stop/ + { + "label": "docker-compose-down: prod services", + "type": "shell", + // https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files + // https://docs.docker.com/compose/extends/ + "command": "docker-compose -f ${workspaceFolder}/deployments/docker-compose/docker-compose.services.yaml down", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "script: install vsdbg ubuntu", + "type": "shell", + "command": "curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2019 -l ~/vsdbg", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "type": "npm", + "script": "prepare", + "problemMatcher": [], + "label": "npm: prepare", + "detail": "husky install && dotnet tool restore" + }, + { + "type": "npm", + "script": "install", + "problemMatcher": [], + "label": "npm: install dependencies", + "detail": "install dependencies from package" + } + ] +} diff --git a/_httpclients/catalogs/catalogs.rest b/_httpclients/catalogs/catalogs.rest new file mode 100644 index 00000000..2a5f23b6 --- /dev/null +++ b/_httpclients/catalogs/catalogs.rest @@ -0,0 +1,92 @@ +# https://github.com/Huachao/vscode-restclient +@catalog-api=http://localhost:4000 +@api-gateway=http://localhost:3000 +@identity-api=https://localhost:7001 +@contentType = application/json +@product_id = 1 + +### +# @name ApiRoot +GET {{catalog-api}} +### + +### +# @name Authenticate +POST {{api-gateway}}/api/v1/identity/login +accept: application/json +Content-Type: application/json + +{ + "userNameOrEmail": "mehdi", + "password": "123456", + "remember": true +} +### + +### +# @name Create_New_Product +POST {{api-gateway}}/api/v1/catalogs/products +accept: application/json +Content-Type: application/json +authorization: bearer {{Authenticate.response.body.accessToken}} + +{ + "name": "test3", + "price": 100, + "stock": 10, + "restockThreshold": 5, + "maxStockThreshold": 10, + "status": 1, + "height": 10, + "width": 10, + "depth": 10, + "size": "H", + "color": 1, + "categoryId": 1, + "supplierId": 1, + "brandId": 1, + "description": "string", + "images": [ + { + "imageUrl": "string", + "isMain": true + } + ] +} +### + +### +# @name Get_All_Products +@page = 1 +@pageSize = 20 +GET {{api-gateway}}/api/v1/catalogs/products?Page={{page}}&PageSize={{pageSize}} +accept: application/json +Content-Type: application/x-www-form-urlencoded +### + +### +# @name Get_Product_By_Id + +GET {{api-gateway}}/api/v1/catalogs/products/{{product_id}} +accept: application/json +### + +### +# @name Debit_Product_Stock +@debit_quantity = 1 +POST {{api-gateway}}/api/v1/catalogs/products/{{product_id}}/debit-stock?quantity={{debit_quantity}} +accept: */* +Content-Type: application/x-www-form-urlencoded +authorization: bearer {{Authenticate.response.body.accessToken}} +### + + +### +# @name Replenish_Product_Stock +@replenish_quantity = 1 +POST {{api-gateway}}/api/v1/catalogs/products/{{product_id}}/replenish-stock?quantity={{replenish_quantity}} +accept: */* +Content-Type: application/x-www-form-urlencoded +authorization: bearer {{Authenticate.response.body.accessToken}} +### + diff --git a/_httpclients/customers/customers.rest b/_httpclients/customers/customers.rest new file mode 100644 index 00000000..1611fa31 --- /dev/null +++ b/_httpclients/customers/customers.rest @@ -0,0 +1,109 @@ +# https://github.com/Huachao/vscode-restclient +@customers-api=http://localhost:8000 +@api-gateway=http://localhost:3000 +@customer_id = 860808335851520 +@restock_subscription_id = 862777745866752 +@product_id=1 + +# @name ApiRoot +GET {{customers-api}} + +### +# @name Authenticate +POST {{api-gateway}}/api/v1/identity/login +accept: application/json +Content-Type: application/json + +{ + "userNameOrEmail": "mehdi", + "password": "123456", + "remember": true +} + +### +# @name Get_All_Customers +@page=1 +@pageSize=10 +GET {{api-gateway}}/api/v1/customers?Page={{page}}&PageSize={{pageSize}} +accept: application/json +Content-Type: application/x-www-form-urlencoded + +### +# @name Get_Customer_By_Id +GET {{api-gateway}}/api/v1/customers/{{customer_id}} +accept: application/json +Content-Type: application/x-www-form-urlencoded + +### +# @name Create_Customer +POST {{api-gateway}}/api/v1/customers +accept: application/json +Content-Type: application/json + +{ + "email": "mehdi3@test.com" +} + +### +# @name Get_All_Restock_Subscriptions +@from_date=2020-01-01 +@to_date=2023-01-01 +@page=1 +@page_size=10 +GET {{api-gateway}}/api/v1/customers/restock-subscriptions?From={{from_date}}&To={{to_date}}&Page={{page}}&PageSize={{page_size}} +accept: application/json +authorization: bearer {{Authenticate.response.body.accessToken}} +### + + +### +# @name Get_Restock_Subscriptions_By_Emails +@email1=mehdi@test.com +@email2=mehdi2@test.com +GET {{api-gateway}}/api/v1/customers/restock-subscriptions/by-emails?Emails={{email1}}&Emails={{email2}} +accept: application/json +authorization: bearer {{Authenticate.response.body.accessToken}} + + +### +# @name Get_Restock_Subscriptions_By_Id + +GET {{api-gateway}}/api/v1/customers/restock-subscriptions/{{restock_subscription_id}} +Content-Type: application/x-www-form-urlencoded +accept: application/json +authorization: bearer {{Authenticate.response.body.accessToken}} + +### +# @name Create_Restock_Subscriptions_By +@email="mehdi3@test.com" +POST {{api-gateway}}/api/v1/customers/restock-subscriptions +accept: application/json +Content-Type: application/json + +{ + "customerId": {{customer_id}}, + "productId": {{product_id}}, + "email": {{email}} +} + +### +# @name Delete_Restock_Subscriptions_By_Id +DELETE {{api-gateway}}/api/v1/customers/restock-subscriptions/{{restock_subscription_id}} +accept: */* +authorization: bearer {{Authenticate.response.body.accessToken}} + +### +# @name Delete_Restock_Subscriptions_By_Time +@from="2021-02-17" +@to="2022-03-17" +DELETE {{api-gateway}}/api/v1/customers/restock-subscriptions +accept: */* +authorization: bearer {{Authenticate.response.body.accessToken}} +Content-Type: application/json + +{ + "from": {{from}}, + "to": {{to}} +} + + diff --git a/_httpclients/http-client.env.json b/_httpclients/http-client.env.json new file mode 100644 index 00000000..1b85cf47 --- /dev/null +++ b/_httpclients/http-client.env.json @@ -0,0 +1,8 @@ +{ + "dev": { + "identity-api": "https://localhost:7001", + "catalog-api": "https://localhost:4001", + "customer-api": "https://localhost:6001", + "rabbitmq-api": "http://localhost:15672/api" + } +} diff --git a/_httpclients/identity/identity.rest b/_httpclients/identity/identity.rest new file mode 100644 index 00000000..21a358c8 --- /dev/null +++ b/_httpclients/identity/identity.rest @@ -0,0 +1,91 @@ +# https://github.com/Huachao/vscode-restclient +@identity-api=https://localhost:7001 +@api-gateway=http://localhost:3000 +@contentType = application/json + +### +# @name ApiRoot +GET {{identity-api}} +### + +### +# @name Login +POST {{api-gateway}}/api/v1/identity/login +accept: application/json +Content-Type: application/json + +{ + "userNameOrEmail": "mehdi", + "password": "123456", + "remember": true +} +### + +### +# @name Register_New_User +POST {{api-gateway}}/api/v1/identity/users +accept: application/json +Content-Type: application/json + +{ + "firstName": "mehdi4", + "lastName": "test", + "userName": "mehdi4", + "email": "mehdi4@test.com", + "password": "123456", + "confirmPassword": "123456", + "roles": [ + "user" + ] +} +### + +### +# @name Get_All_Users +@page=1 +@pageSize=20 +GET {{api-gateway}}/api/v1/identity/users?Page={{page}}&PageSize={{pageSize}} +accept: application/json +Content-Type: application/x-www-form-urlencoded +### + +### +# @name Get_User_By_Email +@email=mehdi@test.com +GET {{api-gateway}}/api/v1/identity/users/by-email/{{email}} +accept: application/json +### + + +################################ +# Identity-Server +################################ +### +# @name OpenId_Configuration +GET {{identity-api}}/.well-known/openid-configuration +### + +### +# @name Get_oauthClient_Token +POST {{identity-api}}/connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&scope=food-delivery-api&client_id=oauthClient&client_secret=SuperSecretPassword +### + + +### +# @name Get_frontend-client_Token +POST {{identity-api}}/connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=password&scope=food-delivery-api&client_id=frontend-client&username=mehdi&password=123456 +### + + +### +# @name Authorize +POST {{identity-api}}/connect/authorize +Content-Type: application/x-www-form-urlencoded + +scope=food-delivery-api&client_id=frontend-client diff --git a/assets/catalog-service.png b/assets/catalog-service.png new file mode 100644 index 0000000000000000000000000000000000000000..5b8bc4e597ed193df27fd0861a3708cbcc5f3c93 GIT binary patch literal 35861 zcmbrlcQ{;++BG}~5`rW;QIaSTWz^B4MYJFyf-p+-I(i>1QbZSRlpsnFy^cCa^v>u; zi{5*$?$5U41O@XP=g`2Loiyp|&fMBH%oAFIVS z%NPU-%2s$T^~OzaeS*aEzH_3)#pp#^9zNTvI{Igk8KVnZa7K?L$j@7SQNGIO+CtVI z^Gf*xuj?S~%fm?ecq`J{N@)wvr9VHyPNQF2YW!4~FU-t{>UFc&fxRYEoqnl6x`Tgb zKJ1QAM>1q$>U7tPwS+HuwDYBlnX9ROPm4Hu6!_Qnq`=d~<#3nae9=ej2Np;&-b7}s zJ6>Q_Wva|=W4v<5qjTiQ(cu=TevvmB`OxyR_p$(d{}QRwq&v#}r4-k z#H*9IfT<1KOXmdL2skYj^E%mE8!hP;wXLn`M(qzJL`E)BaOpfQfW8_N@6+KZU@}(5 znC~4XhFl)m_Iw=Ki3qziniW8N94U6}TOxI}U8&h)aLsjhb#QeuFI}unx{F*SoT^(O z)zoFwsG{(iZ(}&O%bh-1$>CD!=)?9!n6~*u9tO$z956_%XShWVyAPmgw7~_erAzIr zY%0r{HR6d&w$<^F^I6B?%U!Y4<9WU@us546Z|c(t`0lXJ4koK+;{icSfFF@hwY~Jj zRLuN&>Y-*q=N{EFO12;jEY7 z@8$Rch60B4)?8-H&uVKlrNqh{5tn&CHN%-T47# zXCXGlR>$ONWIY~I-^+XbV(bw5lI|(;aL9-H9eWyc!e*sRHN1a<^%&yhd>O+YQjS)i zjk{6X!ywimsJDG=8*qPEldE!jcJtyF*t}y!QLk4;e<^`kwxM$tznKt{uR|lMUAwR? z!@x;(x;$I(@!8DN4{v{R_RA*e?R^blC)7LdVK@(;c!2?*x9V$h5VgU#`EH%%Vpj{$ zbY+HE^z#%`+U&j>O4bT3Bm!&FFirQfnuP!I3qb@6Qx3-f%U!U&d5#j9&JfZj9=n zr$&#@$jjI2Ol0;ZdZ+k4FGsO}_O>@1xl zCVzM9t|ilGu|iyGTxY=f18ES`nIe6Qd0N4NJk;w1ksfZIqAbtZE(WgNB(aNzmR<37 zmFKxAhndX8W=Cuem(g1~E~``CZ{TO!!!iS*oaaK&68i=@g5842x3&BpzfOlP_L~El z$3{rQIG<1oKCr|pZVX1pe5psR6*Cj3#ps+AWy~hSSJr4FH{bGZ27>jTbvQ1@N?7D@ zUz}~uz5cdj1WTUWb4`#yseCqiih(9yPS}>!olT#P7%okyTPY^PHx4~bE-p{>J?E1( zG!cb90_(bnxx=Z6Bho5iV&?DrPuV_A7Z6V!9;JrmOg`*@Uk>_s8+?&PYSy@pHs+8z zm`Gh5uT5#PvXuJyVI!&Puv%;qkLLeu`YhJ}LS9l`;!GT@9E~s#OrGj_>*v(e1SM^p zAL^r63a}+9VlBq*5sadvO@Ke;w#4`IgQFb7D!l@J*wD$qzHLU3%*)L;(^Z zk_pPKQ{rs6QtOPA(ITcgI zeuRWqIm>K>YwNcH?f7nr)3|5rDS||?iQA<;wdN_z+B%q1R^NSRJ9AcWx?KE1sII#3aou$4kpKmyUfBZa zX5Bl(hkX#xG_^|I#MXx5T`6Ci;l-Z*rK<#sLmeVPLPr z&NLiVm*AOCJ z{a>PriLTALhf<^USz+%XD*hp{sh@PeO~^=YrO@_IS+d5_Vn# zlWp|kbF13LQ&4cRX78pwljBKyu zQV$L?P5VAkEIsLF1xdQ>h`{vW>t+3!Djn=RrJsj7*ng6S90=xB;=@mL zdCs_M`YoxwKXXPIY~1ZMg)h5D-olM*GD5*-eBL8QP&_2U|5==vslUqiowi}-T5`tl z8hT8=R>^1Y*ENB6ugBl(t9xeA&+=k~;jDqvDdm%j&q;rP${6}P2bw#YjOk6G_-R@U zH3KaN*PX~c)2&#qvm4mP@xvC1(%Lj5XzWjGLEzfQsY5~o${b__UBIoqI6Ee!;1JrZ z*?kNxw;tUA9me%|ngXZk|5i>X(I-%mU96W~Y|8TpP} zr)H%sgc(+ogD;zOQ2L5CI$!&UE%FK-+`+SWDs_jgmQX216K!-onoax$Pz2O_I{wmMpvAueLf zK{&|s_i8D#$MmFQkJuhxWmcl#VQot-^b|!jyms6=^?WG#KEwBD49R7(zW8D6k%H#@ z5SsfOU**#4QHp^ z`FVA==%cUO5s5evW%gU|@&Py}MfUKC~AfeQUKQPt! za?cql+PaQ}>=ilnDF!f#DZLR@t*Z12PwhxIzlElhXfk;p_GW}M_hm)4bb%jlSP#f8 z*+;1`&a?QC7L=AMT#wY~o~L;nF)iQpq0p|2AIzuH*jpcf4v`X$x;R-)l;eyOgC8`g zh&fqI{-_prfw!Tf1$EqKwH$17-S(nm!>BQ9S&-u$Lu+QVa|tbR!MKms*{_< z6g|b;6T0~^YD@ydlugZfNZS6lZbs*iLteK$`kIm*eNsQ9H=e(w`j9mYV&(ekc{9qo z+#P%Qlg|VsM7z6(#ESR1dDrC2{hw{=+Y>Y@Hyi)7k-O%Y zz-i*zOJY##Gq`U2CrT>AcJ_Zv`q#d>XsD(*(>?7i>aVXLLZsdLQYS~mtQQ*)Cjdh-srxv4z@FE2GXnS6bONwb(Q zcI|`4%mv>Amz=WV$UG+=lvHCxP9D$w>gXNzz5|ovM*`L+7R#J@f12edA{s@_9;=rb zCH`^f-<+AV-|smXDvfW@KE7dTLc3CSzN)B+;1B+EycB()rPY<>>GCcH>O7O8-18JV z`kOaV1HI7{_wfdBcL}}Y?d=^Lir3BXYj;~w3|t)0^Q4tUeIM^WKSDzA6Kn6KgU?p3 z=pHX0FYq}+1b$%cj=v@0Lh8rafVdrHe#PlDHE zj%|&{A3wq-mFKXnJ-U$spI)l^;WNR6ilYIkC?8PJj5^275+Qe7Peo5zSPvT{Hbj;k z%3b(La!Y><8+3G6)(-^IlLWqW!?GX*P2&Q?%a_vOF|3dB7Kl2EJz2qRHbn4XbINC~ z^{%!a%40u6Ie~)XEu#%KXv^2O8A(b?>Q78pj9e3v`Y;OLs9gM+nN$^&hFcYQmkZ?9 zCTJn*x}ig$gW7w*MwTlVMt}aBvUclbD8;=!nH}o4bGd^~+wTG;yt*mf#oSdPhX=t2 zAkgwcLMgf04BD@{UOD0$d3l|WW`%Q!;co;-gW03;520XR>xE3$HXZzpT&WwN} zuT?16p;S^c(X&Of^ObsM=6p;J2&A*E1|Z1sGOOX*J+vZ;*j~8KJP3Cc7)g|z+IwuF z6ieCt)a18!&2}l*)|fsN;VW%2D~`(y4Y9D^F1691jC&$&=P`8@!8KcXvxENCB5pXj zXAF`*UCi@nYo;lv0Ck{U+5Wz*t&KI=b7xWFveFF^3>@=^ni)-$c{@G#sN#@!$&d&B zeB!$4qxrRg=Q<{Fl^z}2UR_Owo_w-J_yg`(#`qFHIAvh3=}?P?42526MJ+BH%twV*ssf%{FQBQ6sE;f+f`j!itU1Kr5GG!D1fUX_a!CSaggW`1ZLu8q0IB zPRW?^N1hUN(=>zXp|uJaKC|w*`FY{Zr5~UP6#S47T(i~AUeFoGx9GJAPZsYJ;_Ow| zRuJsND~;0~Yu4M`Jysu_*KN=h^CV)hZGV~f(b*uP*rQ|Fi?hj4Bth(ZHzDJ!XC#uW zY;`Elk*bUZfK!o*cyW0*)h-1qszdtps z!@*+vY8}g252W?5aB7^~?4J4fiw;b=t$wA$k8SCj0k+OxLOiwZm(P622XW^y_C!j4 zePRASVt^(C_z^ac(A~!iN`8bZEEtp$nLMVs){>9pI=I^9g*nxIEe# z$@E!0b<~`m}*s)+EP1wh|;RcOOao$}EgY7&%&{o$-%)KF9 zM)^Mj#%=NL?dx%y3RfMZ&oKYzV`^hnZfTvI?vpOx)85%CcyXu%Wj~p- z9ybi!K%-gWQi4kscuGHF%{`e)&+}Kcsu!_vD^HVh%0<~EAgeFseJ&^VL&bNvaXAe@ z!X1JZ3D#gO%R(fNUWFCEIOMHP{ZJ9K=?S^XE|q@_x9|PAgwjNXt7}|@S$Sjwot+c& zs$gATzp5(TfWuk>sSXKCVZP01hI^b)y-_;>0)-P}sX!~|KtPz#Pbe2bIH0Yh9&uZs zSE*eGjS&Wbl8^(uRnW z{>0QQk|jKNP<6Htij4PrtEQ!0bgMZkk`LtDb8Kp@cxyG4rG<{dojE}cStwjL`a^5= zkD7a2YdfCsDa>+Fzzl`nc zD1Lf_irIP$-Zqk3mh;4Y(J!DVG*}My55$XEH!h;!?b9{YQNs-7hxzSCJKZG>4B{z% zEGFu`@zGhaNZKhL>nOel9n*}|+lvTj`^_2bZ2W&grl|7p{Ws2Ad?cJ>ldUD5A(e>E ziiqLNRe`5!Kg`auM|f@gm-unE1SEds1qrt6(A-yxiO*SSHEeG2H*^d~*lg0@ch{Iu zY=%VQEWY}6!a3+c)bnOJ&UoU1NEhg=Q_x~ncc7T7*k~fAylDh#)<5tyFBRG}5-D5l zFcI9iekaisR10+N{ageGnfB}IPXgmh0kXmv*^YqK)}7skF`fet{meDy*@S?{nG+l9 zZ;}~Jb4BNRE%2t*fTjh8w>_H|JyK9V@FuI~Hz$t@h!H7%l~r0Cj~dbM8|cw)6@ggf zB{b>I(I|rBMwB+<5@ROvld}YhHm=W1F=xBcJ%=Is+0^}PAQ(nn*rfrwS>v0Tr{lnw zXMw>uO(0X0le``ffr?*scfFX6tJu7_9)q~8u<&>&0vHX)`N#MCuJzydzmb*yzKr9m$G%0!YL=ncB44JWR(BU4D?pV zN@RiQG+Z(h2h_OTGIFrgmzE*I%Y*eO4>7EkYi=c)uu_<&X9ZiLD)~02%=uz5>M#LI z61gomcD?YdywB-zW*=gu00_Q&`g1N%tSPpBCDG3h%z!{`TXJJu{A3%oM>9bWEyZG2 zHCR6>^@!b&jZHbj+((J)iUaWro3a*S28h zib=_1D#=&J9Iz63n(t9Z`6S&>=))K+kbHd$((r|jb}!Tgt+GF8VuUga*MR;NSP^A-q5<)foy z1v|=$j1AsZ8Jo<9EVtM<*4C9By_i5p@-IDsR_?k!Rt84xp`}U0m^6RTCO2wyzQA}1x4`@)ZPP& zvEDBk$u;>{@oVZcJq9+Gn_y1ZYYpkEL5KZl+9IJS%S1g)kqwq+wC;>4oj8{}Io-@J z_e4L~&I8?H!czHX*t_2^D?&OBeNPJsBch-zZX&QxeXu|>krO?N#x_b7~bCLqY zIQ#z{8=zZX#|CBmH#WX{yr9;5SPYn|Se)Qua^UT72jIKczpJv^>=pYIKU466?)Y8C z-Ee(xW%-~)4=9hTYR&m}bb|`lM%JS(bUz;2nrgIO#%_F9OAXvH0}$%Su?hju9eLre z0FdjF2UF9|(P+0CmIO<*c0)?1o|fh%B^oS=j?mbo3pjmMo57a6gDU~ktx=76Sd6f4 zKROCWW)}x@ekK^Y+90VjRqdqg&5U3eK2Ji>&0OD1SaT|5ND;yrIMLdc)-^^a6bZUx z4czI@!wBm6MgKCR2`_6Nk)hghwNOU|sfnb6ypGSZNzubtjCNbPynNg4n8sugM(L@F z_KjU+-%{~O=uj0lbs0)|7$$KqFn_Y?tCn0F8`tD6|;CF~93a+tGD&I|P2G z3{*>c*27dmsAGg1xAU+Z2V}>>Dvl-le$^fvXHH8Ra=UL*RC53sw%x<`7TUU(QA`e$ zO2|F&;Pq&B3z8y7vf}Yhp>FlEZ`)AJ1{wQ2*7uX!jD&N~uOLpOUaUE=G- zW5{N^)n9t;?J#Xcs4`YU|3RGm#6o_y*U2beO!^uVN3}?A>UzkR(MEshp=DQ1wUQO; z5wD&hhu|zN_gQUB44-L*bNaWuab4k7V$h%&NbA9o#p+B5Z~2@%<{CN4FLzVG;1-b? z2%*B0Z|YD>@GPxk?oTCCr{#kUsnLm7Z>BoY?%CB!3Zo9qWP`RG2A46>npSC9;y6I^ zsz6E7hRW2dPv_xQ%_;0DMO&@mI!U+QBx4zW0I~&0(i;aad;lcngFwOr;le6Ani}K` zL5W<`ZfaWNG^Q0f{sn)G3{cgbowf5yw}1O1lc=f6-gB#WUTw;q>GiPC zo4S;zZ>oyCouIlcZ~18mIRCIZ3I@TTLqF^nc|i5jM+^B^8I?{zdl&JXV0#`=;H1Xl zRma+y@qs#?eg$+j4mPsy-EF5JkP^Pp6^edm{jWXp-%5QmLij&3?04J^J`HKp_g86` z!Wec%Pzun%<5?7hyiWLkEaFn?Fcv_2^RCjac7H>D;$G2hkfh&Jq?pHE)=xS)rF!|* z`F*m46=q#tMpvZO;KhaRVB+2C-#kJ8qQr<|UBCS10bSm_FuV7BTF?7<9vFNt6U$9TGgt1D zlEM)&5a&R+(CatqzcNAP8+Se#fiCWzl`@kR59|AQ#iwbodi$4ED>Q!{3kUm8@ak6X@sWjM9pRuM zx@3t)|IwI7hTaM_Yj+@XMT!%XkJpE?k6#Ao{ypk+Lbd5%fK7!S3Px*i315L||Lc&| zyI%$J|3=o=NNJky*77F^ALvdv(&8iV+WSYz?{{NkV~VtY@DGxHksQCPHy^;r7rGM_ z-{&C2h4!917A?xPB2FLOOq%)m<*hTt%pO zEz0qo4StglZs%0H7{>-ihx#3W3V2rheib|6bCL0GD}}h6kZycglDNAX!FDC};Q%?4 z=&YOS_NzuJB{Fq=6Dnl5!;h z@GFiX6E#9lPpo~wYWis!pUDn4CdLGLu{`3(!uS1ocs;?Pf z+RZzh#vY;{iiU#o+bNr14wEuoe5l%7WS(w~<&eI(XkE}-{fZ*UxW}ug?4mj%xZJ8> zOi+TnXBUkq!$TLUb(Gq-dv_Dq8ZRwpvLbTyOP&g!pk^RJeYSZ)U)Vlk)qDm{EqmasQYB3YJQfh&SBg+BXT zz}^rj2%DRoKO55?%(Pwzz3!-D%U#(wWk%1x^h!YB*NE<<3|_;53s~sN+ty`$=^a zY^16Nt>Z=E^K?zDw5mDfglIkm9kQQ>Ss7`=uQcHuA1nxfw`G1!JO~ku{xc(tB`@sh zC@9cvYLY+=FVg2jnlNr_4G3{U2ml}p2TUkK}5_(@s zL~{}7y_=-Q!JgLFPJy5HhSC+w*Z9Hp^6T36U&xGU-QvAxU~14!c?|#6>yjOd6xKC_ zqQX3>xB^sz`+}~swK|2@>#rwl`XEByK+bAs0%Cb~S|O(1wid3;qlR|-=+pIpMpq;G z?AMl9#TjRC*h_%XNF9b&Kq62j(go9JqtvZO`N0%AtnFrX6Ca7atRa zl-)f^+tq@ba{r`~bdem(t|Zj?+mwS0(#F;{m`O)&kADyoYd)?-HJYjou+rggv8U&8 z66owj#C@rT5;iUweoGx50GZw|Z3e55hYHY3{8FKNiZVO5bj%aUNy`24zH+%Q(hJfn zj~91Uw$j>r5x1=IHVjc;JKr*z=3_ffn0kM(J+E@QDzy@CjoS#{8d)}VBJv<48KmydJmqpH!DS%sX{Wj~`~A%EUpQk|nx zPBk=294U)j+W_7qI+^SxrUWaT<;X?avJ+T)mmBnK(h#>9MgJ#-0O0I<|09qyl*~p~ zxz+aUn&91x!1@oG_iwMSHeSA(%-0Pac)e=q*dA>l#(8d}x*ZQbl?l5?o6-@z=^pfL zGPP#|(HWr;6!*njN&(@G!u!v`)N;$Y!y<(_-AUB_C{Qs4TBA#MI$paq_u7t9oq|wm z0{7hZb(Duf$tsqZ`R=+NrXo5o;&{Ywl?tjQ#9b`S+zhb_u8ZGq-Pd3LA9<{xAHEnk zcHk7Y`hb!V2I~u_Kp8(X^=PwBqMnVsLk;Gc43h9rQOC#pkqkl#?&R*c1+(uzS}ZTr zU;ip+GgW4yZgvRLPuq89$eHxpZ3@Q*MgXO4ROLrKUQtL-@QT701AnHlN+FMB7-0~W^kAIc_+ys{CO^cjq-2vcIL%aB9rxo7HlouCq(jnJ2l(ipU!K|py z&HPym+1df`In!@k$1XIz1krgAr*Ou!B^`*#+;NMNqX*ZeK3f%W`>xo}?tOhsAT+Ea zDD0OnB*t_&iP1_T$iRQ@o!N4Q?{5-X6|adDH{?xA?lIAvjn@_4CS? zt2WwyhJDBCyn}`aJci9j;4CqBd`b5fpy(pf=Bq{F{rpf$6klOmz2FIsTQy5_E^KW= zL}T6iOY#5z$^e}Uihl8`psAaTB|vI?|LCk5IZLcav=?C?Rp?QUjacfNxR3==e<)RV z1a@GGs~woKLAGp(IpL)#10AD!I1>5Tu20eQ;l>8QXa;Wgg(ZuojE4!`KBwAq;q?uc z9ve%2zzG0i2ub-N%9y8JZkQFpv+l3Qyk+#oij@?KZG4qoO|&LlDTq#u-*UrRhYiFW zwTZxH02EnG5K#KeypeJ$Qliw7mH-5~F1rauR8Bg)E#ln-i3ImINndN=`Xs?|r0p)? z1bS{klrb^ep^+tPWCFG^S?xqP4d1bg^hseP39tsW63b%-zF)0T0OP}m{{Z$u*7G3& z&_hS@uC^ye5Cty4S1UD6 zpq7}~*$e2$r{2Y;X;hL{_U14=J#~&^ceXzbpvyjUK2kohWkur4lLK9t2Azsf+Nwna zcd3dN7Gwu?spc$Jgp805Ua1-_1O2IQsr?zU%W6Bv;dtRsfC>u)BA~iwTzE|aQVKuG z&{mDw8Wtetk6BvT6G3U>4DZdqU)+rMG{n05I=6Rf3mAx{L= z*Y;4fs^EED{=AYj|M61?e2l|6>JNn%z0-WV(9DslK3>u#+nHYJfKr-w9w%j?N*E6O*Ca4-DSR z5)YdHE78QI<&gq#gg4S*G*_=qLXqFExD5HLVX4!JB|Exul2ENG-oKipwnm8CH!tW~ zIyh&A^Ya`+b}4S7qRuNiH`QwS^xkQF!^(u*o?pN~vG;xsQ2Xn0GxY3dqbKw5(9XGA z_7}K(o)Sdp8~@gjJ5ThAFD9ZBF)pG%`M$qFC*0NPXH}+*4C;4Cw<)S2sxJ>mT5ezu z4*-Ns>$>4c)({EGxS6^OtDf$kxvSuX@=yz{HOcMhsD*YG^5|wPYzd4vy=|`#Td#V~ z$IvYCV8*p5CS8lfdPS8(<>$^#=aW2-78+kGpv426%CR1=7LyuU*PFMvmXT}*+U)AS zPiIXeSez4M!!v6`)MR4sZ2Z~rSC8xpOjbvLkPEiZsrMoe&Z5tnA~eLUVK8%h>%EoFP-W zna3ZMt_*({103a**=Z@L;(e+57MEoS3K9IL>s{Js*|-WW@WlnD&oqRb`K^9{C`3t> zYSqArlMWv*oL3%-3&4)vRxS>Y->|A0f|Y|(TV058pki&&j|wF%IgK}i<726#!Cw>Q zSL&VG9}OrUmV%gYEpmY1l8(JI0&q_+PkwZ4RZU|XQPn^x*TidhToF`>%ttG*qtn@V zhiB;!Q(2I0wF`_k9()D?zPn-3)Ae&M+fj0lZ5{WOu$g>;UAtKc=tJUbbJE}8Z6MDZ z76~-hL|P9TvIX=WaX()6{{aG2+$s7wDM~S%c8iPp ze~8f5Xuex+gLqolm&u)4RG4e6H$e|jx^kNyB-Ids9By?pr31aHp1|y$14T3P=L5OC z?JJ#3BpBAE_uuz&wl(I2w!?}eP1_00aG5lK=C+-hflI;=kEk#U9Kh#7>BTH9Tkeu_ zqdnZke!Laxu0RC;ehJo@<|C>qU|gLp>8XgNWMON@!E3!M4=oX}8tPRMv)iw;J7mT4g;yUi0XV!6CZ?!CW4%zCEn`PWB!FRCAFlD z)R^f=Dav@LnbS&&8kSc6ep__ZVYuwa+UDzx7ni5MkZVI38qKa>g(cqk*4LNQzBXc+ zvS}rAk;NuAPHHHpb)fhkw4fD%%wp`gnGWlIHnu?VCfwxyg+yElFusz0U$QztX)Y->Ar|l>)R7csp7eU2|4p}vLcwayk%nxX}z z6QChY=Kn#Ss{;ft?F;l3TgaIsQjOn8X1xxo9Jd3_$YKz;C`9$cL79=VV2j-CBC6f4W$Ziw<@E+|jf) zovOf*-=)+vLc*fmzx=wNCde8jPv=?vAadNR8q1Ri2)dR$Mk)0#_4@(KBB2BrVUgD( z3=8A~tO1fN!IP)5M}ZQb>qOWJ;NKTH3mABa(G-k1!OGt5bYTx%+-Ez)w6?Triui^* zX1~fgpKG$p&BL@TOBXv4!)-XV7dd9RatZHq<;Jj$zb2LE>+dd{*NL+hST1f9_&83* z*6e$h`AqiS@A-ik=?uCGBT@ub$M`J7c-pn^z49gqw6zC_#+rranl@P zwWrv%dy;Q+Be}+Csm=BFx$#BF=3POP55lhf55$f`{p%XlX16duPx-WGaGK$6 zpIExB-_=c6El`hL?1S5vv)1ktfQ;|U(dT&JebDo*nxyO897|t~`*hWx!aBpm`}g(< zob}Kv8g+rv3mr(Ec~VE!BdoNMAVPT;PS@3q_w9m)b|dk0jAk~g*M=1tqtX2}Ih(N^ zhnA1No>m_c%5Jg6HBwBZc-QyX<0BKyu{I6u22i<`Ba*7DB#`hd#4T9G`l0z<8T;!)8RT z3c>5WcG}ZjVAl>4Q3h0ukKf|3t$suCWE-1lhjbz9ucpXIDl}yEi(WB+I72AiX>W|Q z_UVYC*tbjGPMiJqjJUv$@AP^{&Oe39&$Z`D6(n;<1Ub{zxxM{qUve)8mkG zpBFwapDP9VpSF2=bNBbVK2{;jO)RTw{!7?vwV8{K}eCd?aFHc=8RjP$c0ebNS zM#^tgsKYKOadIShL`|Vq5#nV2YuIRXB~AjmFmqR$!_kLVe0!ABXH1(R$wm*z zT}k1rAuup!BJ(BstPDkY^ERBU#U9^tc-ZsfI~HyZ*S&nz z0<5KPYi2JaBM#*!|4KbPfGi98_0v9zrrer~Ct>BW)l+ZmeG_^hihlmkMP;w%1F;gD zGxYBQ%~q>zQ=ddH8qIBnExAk8%P6i1-EWZS#~O3Wn|F=GFo_kK+z7pzmUDuFGqd2!`xG4< zQU=Sj7+ew6w%WzdfCNo2+x)1o{qrkg!vVGZtJHB;IhBjUBE<0x!L69@&$(Q=O}=bNfs| ztusv%f>LtSD;=P5AYYXL-N?L`#s>8v5ne{|wJ-kHIsVDWj2jcWZP;SWl^DB0iNWQ`^GZm?n`qWe}%eE9Q)cyc)_w#Vf5YF5cgza_*SjdzQjn2Ayk< zu;E0abqq~c9!q;s*LVKWEmpd>8PUdmtE&*vc?3b|;8Od!wp~Gr$ZG;Z9`jCeY=PsO zLMxMo;6iK-3FwFv*o*!GivPMG{oA%|k>K8E{suhy0~b)q&^Sr(qw4nUYZ&YO{Sk zRg8Gdicl!cSdT_UcH1AgWF4{Z$I>pQ7lSF3Dqh?EPiFxK1qzlPQk9rUNVy#th|DM2@ z_+TU)8Tb6>2}Z~!|&6})o@x8Bt@c_3=SK;uKN20f0>DEJg^%3oZ zW5r!A-OCFi!J5O!;~;|goJ)nt@9bRDO<@`h(x1u`pH`+R(N>@`GP6Ge@zW8v{TH*_tsxuD+DiZo9vmGPAsIo)5PG@0!fbY-Hv;aw|)1 z1hRCHtaZC=`@!5D(Km(OiUI{6^BU_d;9fWVHg6 z`#4*O&po3iYbJt_i>;=j(0KWU`-kwl;_RchhkLd`P3j>vVMv?r z8rFAkG6c4gk)^#?d2k&}z_u%D+g%f?kDS`JbHwxzdv3pnT5v;aa}9UKjpujNDLHGh zjuL$i-ZT3MTMZ3dzV^U`Y`?KcU~|n?H<~s2Fmr}O%NeMS2N^UD*sA{0n~5`mX=|xe zh)OA{qjm`sa`qTD1G>@jg9Wr2bI*iEYvY;Z|Ey^o`DHWYN zHR{gs6gt*oEY3PKL^!3PuQ5YZp<)lu=eg-gral(gd^Y6YV(Vl{0%~~=D9^pf829|o zO3C?&VsJ=alxF=RcY<3tuo~<>R$x<+p(Ez(_0{B91#ALX|K{BLuXetFf3vIRF+iv} zfY6YJUd0S!Pp2xOxYdrPhV6hJUHS)-#n3j#&#YYXdAE%g&ByhS~3(!t9w5HsYs)<8mshGAyyE&939a5y@o~FVy}USv*dY)9<@&v}Xv_`NFvj2P# zAV1fbBNus=3=c(gkL`3+-98dQ6o;p1Z35_G=gsGl5RR6H@2o^R(qOFxnLfps*K0&Y zSCUW81il3yA<9^o&^Mx=GP@8}dF_`C{{U!D28M<+y(!X}D#%Y3_4Q`Y`pOGi3Bxf&g@ z0w*tuLX4<$7}F$344=qDMvdft$up#q`rWd~S@>Vmt&GxZ_3_bF8#}}k>MX|oOuspw zCur?Lyl|;&<-%$>-)s2?g5G24LFJQ)+x;JRNwL=ky~cx9w~390W2kVv&G_^C6d9=6 zaZeu>j3EzhNXy&1f}gaux98v=3O1Ss|A3E@XEceGD$`Q=^sYvD1c0WAu1QvUaZk7H znI^zYn5Q#O9wQVO!|gasq2Gqzl#R#P?fw-p=Y>lxaREGFW8M6grz7tNch^a8j05-b zsJw34P^zm6$oOu09*xYp*WrX5q2PoQDUn0d3l%c**XWv= zVd0Uevj5(m#h{I9$j77jm1X#G4Ukn^-wqVt(P~G>6d^wd*V`mviOIgO)51w z;WYG#0~G`0>>t1l?(XhRF9%y==Cid^Ee8yTNS#8?ofq<2D?cXH(d5Q`lP>4uHdjx6 z7@U1kKksd)qfi$pN1A8Ved733s{Z{S|4L4|tqg(a9Gm?1@eM`A*h;IQ0%LQy)rXjS zn3Aei_Ph2^m$Mn!I~aZ%tSTznS`Z1!uEbE;mbshn5+BcUn~09GO_P_cFJIiDl`P)s znivp*AFPpftxVcQt{pFM5s|N2HY0BJEF~H&>G7|LUq!X|NFn8;il+98ZK?e@E!yp1C?LM8m>?qUH9^ zcYsdAw6dz^rp8MTBSw4Pbm*6qQ2ySVIXSBQ`LKXQU{Z9Kf>%5;S3})i%UkBt zkh9-J!m#HuxP18sN+`1rLnhkvt4H{;Tw(?+{fI!|(QH9;a{~Qd#QwgE#_-UBM9vvq zlX|K|xLyff__J$PcprFj0g_8=g?VwASnx$Avk0-avB}k!Q$=r4BaF*w9JNCOX6Vq@ z`cd>*mj|3Tnb14zmFq#6K%471tcq;7GTa~+@4vxhu@8J%*2K`d$8^0*$k!_#8UKYT zjY(v7sY|P{Jk`NUzxmg_Q2b5GJr()ghJ=u#*18|?<7ILhpYi{Gp&a!EiE z092;2=ZmB;c$?`LzXi$Ft)F7$g;M%$Ekm2`+Ot^CKePktKmW-6u;-uMDEis0wlCDL z4Dt}QAFPv~@lvu01ojJL4dV0lc!zQu498AH+lfGE509L$)#!G2RICNo zM10RBUR_=1m>i(*5KW3GJ_<^17IXV<_47VtWmCVXhzXkVeG<6buALlrAgPEyAgEQ& zL)S_L>NsVOfg1tT>8}pGV!tXe9L}+W*B9+or50HiNRb1A@o`02t6+6qK8pvdtKu5g zR72a5w~FpEi-y2+`EC{>XudYHPT&^*AAuZfdB$)frxzS4m+1ejt`xOHh~)A*uv zcQdAnhW3OZ^NzUT&yE&sEWdS~=ci4b zLgYymW!>fg6X<1OfX=AX#Mz(J4js>dTF4Y`h#RSUh`$2?OK((L%j9k~?1hvGgK41O9};9|ljd2tVlfm8#!#0gsk8ra`mo z$*S*V>Hv?^*}yDKJS}49edpb80|$Be>mR;(3oqn#Ne$w*v%wwf{}oO<1J8LRb*XO9 zpdqcEfM>Ug`-<-e>}Ausy#K{y=7!)vUA0qe07zU*5Z@GDNdoqj^!v?70@kNTHb)EH%)vGuayo^3^r3^ zoY8X{U}_)erg@1Aem9e3J|JR#M{rD_jQPWDr>d-5mt*M|Zvyh{60@Q{SgTaCHVm+i z@EZ_{NcK(x&n56g+6uaTko~DOsfhRQH|MXatmH|>-T`Pm4mbXvDxg$7R@9XX*QQuN zScRW5fr~h8NXYpRkeJdvtd6{3i#IiA`n_A|x!O(%ngbflvdWi5X4irM1Ma8}LEGhP z&?wM<3h$td*`gT}JDzA#n{BQGZ9!LS<^X}+YWdeY_t##{v&1|b8FUzUi^x@j#G zB=zyiDHo*2Ta{jp2v+&bzJNC2o6|t_GbB{#QYYby6i#NWHg3n3$Jy*C{I9}~o=~J9 z;QX2O&E-A<9(68b(JOod;Po%~ zDC1*ta_!-Oq9wjZ(lVcT4~=qra3;LWzx}Q&fR|bZ)flNDSi~mokG1Z4-b2XSd&Pqyz@?YSYtZ+G$SRWvu>d(J=v2OZ=bKzB(?-ZQpwkK_wNW zr9@D=K^m2iR*+8V8j$W#M5K{!q@|@hRJyx{?(WWa&A9j3`<#2ueeWCo;|G{|=9#tD zv*P!yn6H~-?gdJv{rm%!i%xe*95_WjjW(C?kGtteTZwya{3Z{yAnu|gwIkOEx$z5O z(k->NZhnk*g3gFJo%w`b51Ga_Q7e*9w!gust3fV9yHMsEgSGgTXRahrUi$*qgDa?X~VvhPyBh9_~Lj#PF@%Ke#&yp@JwC*9Kp= z0PU1z_`@>~y0!LQ6oXb?#PejbP0hZxJ|ty!f_|FC#>68H=8V9mTOXXyPlDLoa*Zrd zahv%9__)n;NAX}3N1sc_UOO~jDPe9|e(w4xb?jF?wDZIYJI|GF!ApyK^J64L0x<#R z5!C7z(nN=D?S*|gM5#gUd(%wKWj2|^xu3$4cZj7|HD*CR{kmD%dbdl*RndHTIFN{Z zFrhH!-K`@o_^sdzfHfibA%+LHm**dGT8u=uW>7n4Mr>?LVT*5N)T6T%U%g-V=#G%*=I zV9uh45Q7N;J5?#=wsAf==bN9Sk)eH`&4G@RMNp@h{FcGX8NPLqTg z(?6{?X@qgW)R1@>O4J9iW!Zb*q}o1-#+4I(@qHVcHswy-Z1_Dbi?2Q7S1`MbozXwG z&V^Y!8h`DaGYW--(cl}94e9}J5i0!`Efq?-jPoh?ZwG(xGhRJ&`Ne1z_JO{m8s`<4 z7qz#5Zsbsc2|~~8_NSg1XFiV$8#+bl@gT$eF&|1zN}0eUxvp3I2g|LD<}ur&&_Zq} zNw`Bpn+Jqz)A-xxDt5@@KPeHn<;PRDFd8W1C0=5py;@#ybL}``9fBt0V2}$zAnLzi zWds5IZ=2;m=Cl7arGs?|rd5=cRUxB{`>ujI={c+q%g(iDFAkk3ivS3Y((-;jmvd>8 z? z1z6=@4?1f^>b<3XSe&(kQ|tv;+|;d4yU7oAALE!-lT72=1cE+gKQfle$ZdtBG2v-s$jNirG)7F5T2n&$tlUU;Xz^+-XYHVWg5o=s zSauj+PIFAUG5n?g`p(^_*vdRT>tG4s0a3g363nsmeL z!)B)h0+>9PR|H%S$(ja5Yy+mE(IJ93;JZFGjrqNnXtzvJ5~b$nj!*W){#em9Pf3ps zisv8CEtI_lu-=_0AUrg})Z^8{F1GDCaw*M@uo`IME!dw^r!n55`vx1Rvl|Ps>O_aA zGS8T-8~hp#FTzxhQjV3Z3Cr5oRW~hMVwyK{6pi801cl3#M-Ljbce*r-i)o>R$Z(eC z|1VjUoO+LQn{!+~yX;3mN*@@1EjGa!usT51E9!B7G5>^P!I%ZPRSZa%ytNKPBNX%5 zWJO@$P*znWq7_4gUkVc|FDt%7cn%b0Yvb`c?&^Zn^NCeA#Bl?w{oNt&9{(YQL(l*- z_4Zwln|ZCzs+&Igu{W>V2Pu}9_(2Q?c(^S4bq)9-GgOGKhaX253v%g#>=wT7wd`37 zRzfYqa__MxsIUal&qvAx>d!x}LY?=D6X<=i?sm7CuklE?AnB#xOx7u1+xi1 zj3oFGNgk2o_uaJ!zFWk~;!#kj(9bo>UPsgr z!yj_$erlHO)(AKsSFg`V?2{kb;hy*vs9LnwPq`GE=~vI0vn}&!Z+mC> zb=wYW?!GPBe6Rq&!(#YYdEL<}R+N>k>CC8IE(4qNNP`!Y#c{*KPQ=*e@1VW+UXmB+ zLLn{>!e}!qP|pTN7QA&`33X!F}`g{VR`og z9RyCk^wimI9oirt^jqUwP^N@XD&PKQd*hjS;7ch$EGgbsgFcyGG} zyC0X4Z$~@Y%*6_jJIx+WtEVa@HN-K|o5ETQC%%8}!RjVhNL&rgFsVA(P35~q!3cja zvtKYLp3PxfZjuY^&KfLoYaOWOolR~+B?aOzZ&Ug6(R--Q>~ z;4(}*GP@jd;G%O1_Y0k4@+wvq1-FFD7t!3b=p@- zjk;12L&{wIZyaH5VSMc+?_p-f(Vg9d0DSx8M>^*EVs$K2v-xq3z$nRPy<6OHzf!TS zl12|9b!y6-G>HW%tx<4wwe_p}$K!)vcB?z|RxIIoQ>}YQ(D7YmuV#zqheQipU`O#k zfRn&I0ZNWaz@7nLc3sg!9?a8|lUKVLfv(ZO$H#0mnBP*$S3`Dw?bWI*SBGO5zEE&Q zTJF;UAdcB{ZUWCRU{|{JKUiHK^R|Z?Svh7!*o!*>X^13IUFC-ajxGXQoAuJ2mKW100QxBA+%u%|TzWelH zLwNmrP3R-=7S?A^8O7HkTVWIZW-S?pYoYLU!XEwn9uGT_zOKh#(K!r5SA(l`#4p@n_7b4mx+IQ^oAYpas^ zW%O^0W4bMpno-&7ZXj-AJ2VSGR-nrM+oGWxKu~`-Ec!d4;Gfl}fDAb(JxvV|JV@tV zP(sRgX=^M$oW%Pg(^VD9KM!C+#eE&$Nj0PA?=;I$Lpl)CIQ&Hng`|gSX2@}!qWQAe zQoM2lj7uSQ<+Kdy$gKv54jlm<>JEh->c|DhKY|^<0`JB_^d|TNjc)umZeP{&ihtNJ z^cesfhI6I)-PP)*46+X+{(UOy9t_ojtZI6b&`<+2*QDCL_aaFjtf|!tc*!nTabR~9 zHltUw0q=Ajlrju^rJ4i8A0{+8Q^lC$&<~*Ix@j;>>e9!RCRXHsc4Sz>Fwh88Zn5*; zm6@wO{1hp1%K`PKK6z-$uJvJafJoz@Yc{6 zJ!-&YN^#5Eq-aST12btL6+A!8beCC_jGOQCA9+*~^6V7_Sb6SF9ECP5Yl?Y0?%%*9 zH2EsPF*L^RmJ}U3U&Kj890}jmXkJKiw|L>R?a-#>5v6ZR1ECM?d4=)f%oVaLHQ#l^ z6*Zg?`{|=wt#!H#k@5{h4d@55X5n76KA;PSGN_Y8ZfEblHOaN_EMlA8ZB?k%Gp6*K z5gw0C{~9GNI@Z#pgb=~j##;2!HOAZ|gIXI%lyjA`)dl!(nT;^gXd4tGtrm8_t0Xk` zM~*I&>nws18X1@&P*i^S)+TZ7C3k_XD?hlbXM;oAV9E-$jd$&_B3wR@M<4BM9ZrvA zjoDmg8lD!OY4cR=D^h{VFtB8WFXHfOC$NL`B={{F_-h{e*Q4^u&FI+GYJIp|gy`p} z(vM8Q`OQLoMS-Xof&GQxCiRQ=gu-0#$1MA-BnksX7czzcmKBrr0j;8d`=3dLxGhF- z4Ec4q6ds;6Qj~+Cb1^0*#`c=x=(?`J{0=)Q%UL4?^zzjPwI16m3PbQ>0i6TiXBO~F zcAr#4b?)C_U1of{g6s7F{^*w+S~ABRqv(E^!lCK82uha2XaJwL1di>5mgtQeYCn4) zqw0KsKb~GwQq5E6&)*Yz`T%(l0!N0V-9>y?V1fWVm;S<#|K_>$w-yjY$}l`*A_8?X zoloEjJDSWfp;kv*V9(LUb^O@`b0sJ>b-F-i^*z!84Sbkn3%Rt-eW%5j;tDpORqz}D z{yg`J1NeT?NRQf^A8t`Srdp3;x?FE<6Nu(P()omVvOxn56i$6aRl1X`{OBXv@CdbL z)T=kf@qrH@hNy^ktkz<|0t}@Bn?=UhsppDF$Pal|}TBL~69&U~y>N*gV~P&lpU0GZ_8D3=6-s*lZRF zEVI5WR+}uv-KM(Z@)VRI!}EO@k8Ud0ZhJ1aQy88cBs3HYw=8`Q;0 z?%wSVJM6_5vn$VCm=(y8r)#O&Q$bnM^Ti2Mj2g#EtvcCzVvXLU35HN!@ z9RQ^@+OFOfRI6Cch=Y50E76FJodFSfO=yUTImu>iv8@ZY`b^TSk-|Wv2pXT7!Vvy1 z8b6s;EF_9^gm04+j}&@%r9|MnuqH#uD5%C5**wMqN-0M+OG?Pi#D6y1;ug&4f!wuL zSVn<-ohg>{d_9w|S$&ial;11KJ`365f2gGrB;eqgqRv#MIR zz5>ZxomQMA>O-%?VAK~-B+d0Q`zi_}c{^avmrH@{DZKaFmMh_vv4rNrx9C7V{0hJq zsWI*BouJNWVPb@`iDVXfOAm)5Y86ggzG!Ta+Yf<7m(lASbi7>4d}go&gpACuT4o#q zKY@a)nO}6LO0Wj6TzmqEb)04Y(H|hb2(+YJGamo*A0n%gRolL4X}!fF-3TO0h`-rl z5%b{!xT?f~CTA5z)Mt*4HVs4}G$ zL@@v}SZizS6nqNq(~TOaJe3A(nIcDU6UkY#Vsjy59F?o(GjWn6K7PF3@92<5qa;Y` z$HTfP>H9%VG_?^O*2&6Bfxbm-i;|iX?sMuLjWQ_%8z+^!Woq`gniz@#kQqxsDRp9) z;kPJhS|uff<)4q1OokefPtnkzTC;M8Z1LXp@#XNl)F*DE-p$&cUw3|YE-8Q)LmB8n zcTLL+FtoRuD>WCFMY0yk7GHdr>lPcRAcS0W1M4l-!8IIUA0wLD69CrA1nczoP%|PA z_RZg56!5>d?B`0Y)rT*UVp(prpCk)s^T`Am42JZ9-B?n7SG(izFq=O>f;ZKpFx%cT zLrxOGJG^S)bdYhzBKh1jb@n%Yc)%ItHryFx7XMPOvOU5oH_u=XRrY@7KzfGVYNT2m zmLy~+?vK8V*V(pJ6&y*|xC4QE1pW5;NsH(aM2ftRs18FQ{Zz|Kz^$$IGcfl+f)w5Q zmsKz1#dc@{@8QATVh1l@aND|u-637&O-P7rC0kkgHvv0S5ugd#(EugB6nqF96EJ1* z|F#(B-{ISA^u)XRhUfkEN=xpQWhend^mQ|drL*hQ@X3&tYZn1UCaCYpSo$w6CTPDY zvE7~XU+#=?JK9hUt}pg4L1|U3JiPPWp+;|3QTg)pxc<5!uiV``f694Xx&E}vBmG`} zh2jY8k=c(%jnDV}vFKY<`i!I_bepdV=a*$CwE7(%9ULSc<_HZs7Su@xKRnJDNhocT zAUH(5=xx4 z!hVZN<4h_n*Nait?@kwIm9=74HU&=CatiwPq?l_NEQ9XtJAv{xk?yD}N=%l#^rR41 zUdmY@^~N;Z{2_cZ@<^e^gsR|uYe8PbK#ZKacE(e}G3vKhjMCV*fKB2H6LrnnwSK8< zTU%Rx=ItA-hm4nL25NfoccQc`MxX=s$K~s1eHZlY3;1+`^B5&amgm@9T~vm zuO?hw4`!Bsx-dFw!#(jzMo+y;QphA`xYxN5i5*@X59z^|wZ-&PeB_H(lqJb}>ryYM z*B$kkd_~WH{>$gxMxQH{vsE>x7Xhm}Rj0Bm%{4ryK`Jf%nX#QDp3a5rsEjPlJHdnhQBHTH|v4n zC|YboV-j_RpWQS$d;<^)8S8ai9gnMJ5BrmyFTmIPa!A|lqV2JVO%q;<YVsiLGEN45qy1pr0g9OokzB5o(ir~g_z0F#+Yqb%?(HhD%^@I`)XwWy0Umb$Lu&y zwR(HLd`jD6&HEYJ^f}q(7F1}8b$6+e(?xLa=X!m)Sl%u=3cP?8nshj15gTn%>0<1I zZ8Rax-&<150TGtG$pfWT^wHu!Y$Bf;7jmBa)zNNQvldqTy!g4==jO_Sx?R($iyB{U zt@JLY=p=DVE!GUi zz>|lVx%0xzBDe8!3lt)$(qf~q1%O#i+%O?9geyQ6jz!`B-z%xQigSfZlGKg+_Ydg3TzXM z^X*yLtaCHTr;|y(gN6H$l2E3?!gslk8S{aRj*yY7;B+TZU~))3!!Vx}YJcqT0vJEr z*<*iJKd%aIvNU&EF(K)PQ0$nvTsbWd^h8||j_?ovM{0KXrUs~@(Uz6dq5lY1)N7o5 zvH$wJ0glyJ7{0P`#uG96@haHpjjZv2quw$4y(*WNWSRPIzdvEr1e9<8CzNazu6Dx0 z;{u$62bYFy@@M>*7pgGu%{A45Z%e<&Vm=A`9?meH1b2XHJhuW z;9GAM7THRFHr*&oBx>c}LQ&TXIlQk(o{}L0k^dcOTNOor>Vqh*rEISvn!&@Zhp%vU zadS7I zAK27HbvE70n2)*v!C5Fthx4}~`o6$AO~<({*CoWOmx~9M{7sf6(c75c{o(FCwPT&L z^Z*xjnKk1o+8>b+iklZTP!?c@;4)K=Q63at98vg?O;1z>Siz7!csoIMR&t`PL^DUI6C3~xoF*KRYquoS^a$h}fyF(_!C$--W}a&C)HWhl$J zZDaNE&XM7qtCVLvFlNJi$sA*ieKd&o);D-Mv5wm*(E(#fA7TNfHe^skoA9c6!LJ}1 z{fml&ZK^5%9sF-hz+3{pr}g%%mlAs-f$*&L$%yZ~OPkY(Sx!{I^dc|oP?rU8|8!HDrbx^{3AdEEr^Y^;1`fTP$$6Ct-1vv z0Y-R9hoM&9Bxb)-j>JCBO!b`wF3!<}Ix5@U8>`cKfI%E(cc!V7TM;G@d1(oC6RCU= z_nh6zZmo~8K4WjR@i`E=Sn=OUG+pdo)pam0>2Gx```Cr&E5^Aa+0X+!D~Jl9&B_hp zeERCPDIQ*7s)W0;=>-!wSX~4>^QT!xQNA2*7hfnJ#g7#QkQHY1qhRP?-pIa5raxv2 zee2b4oUeO}akhp4JnY)}j~c1{tB~KGR_u5m+O{PFH+^J?y0IKxj=vhKN57?xmA7tD zdZS2b^ib~NjTBQ*AogtEH&mi~3niGRph3l3zWz}(eI0?Cvtry79fV0}q~RO+oYtKU zHzisumDmwjb*_WvBNh7ekCJJkf~+n=1m5iIR=afX;dF4J3WH*Vu_5??r@60eT+Lr! zzF{UFqOkc8Uj~l)aq?wv*Y_k9TB7zfS6ADB9$fmI;Ji<_Np}ufOQe)STltiWW>^RDwpsj@xoJ%Mg31FPvmD@+N>8wZoY%t zXwP`hBuN0w4S;q3hO>wGC14hV(gP$-9hjto8^XnwB#@Rsx%=puth%J4&`~H9YJ^L9 zw4J{(Q3iX8dsNbI!w(4|{`0RAzDBF4kor50<7b6Y(rbYC8cE9IyUnDe7A$VWPye%A z1eZV<6+Fs$*q}W5UPi*ZJx)$4CwhrGwm{u0u_H%F}N?;oWW~c^qB*3?N6^gJ1#**QQp%jev{>kJy z@gCuKx`Pl(Apd8W64Z3zR>AVOUz zV@5n;z==iX@aGaNLrO|TU^4I4$;M}wH8hqj)SX(y1_#5rP3+gpY=yEV?b}?yJ zFEKU*BP8K1v+A=+GW>8?CqL+8mDg@IGDv}8nDn^HF|?29FzK_WReoL#b@V_xx}I`` zBI$>LJ6}x9(JH$5-pW>P{ECOIkpL$mLQ~0D;}?#r$y;}4sb9d`E94pIT;*E*nv@Jf zpr0u%+Pn!zvN^TSVzkeTia?d*tz$)LvR;4?THLFeDh&_jZWEK}>w24-+@f0n=EPf*iAUU2gGybWOLSazQ9?oL>#6*HiZ!SI87wz=J?~7DdJ%f*SHX z@cGpe+Z+sF(b~Pbht!ZTleq~F&e=K2zWy30keSMvOsLT^>%3?_pzJ8+*GMhJJ3^-h zOYEBb_*6+`a!;f*(lLRU?2OCq9?zXh*Z0%(Bix5#qN`Y%xe>Z&iQZ{jIYJL1`rtZw z?!?cWzL3OVV&hHO?=1weL1*l1*J?y@*=^#oo1Z*@w*S!OG^nNzHrJl~I@!F1r%Y=& zRs5smxHOLn*>mImLdj+GWe;zC>-kGjMLgFcsFDtQjgdf-lB>%x#Wo*{a^P5`S6eJw zUaqU1ZOlY+p?qhNk=N>0+}9usNRd34J%rApdW3`yq|_Xj>d!n5HhT&=r>`c0uNU{- zeAKQ^+P6<>2GmNcuWysQ$KrRiR;GD+!+k^EHg_;}&)rXz~#M2D-{P;~YKbLU8bj)78H!H&hPdq@a4Dd zh0czauSS-y4d%ReYp%aAM>h3LV_mr(_|DT?UI~jRebwSZZy7oQ{4bcnG*Gz!0bHyQC9o5MJ0=4<(LWI#cHG7jL7)bMIJz|+MEGd; zN40)Z+qqNG~YKZGut| z$;p(s)9~sh4J%5p9@tdM@J))KCEfJYcXFed1%faTAOa*y13$gdn~43!;W990X3NPc zJKaE#NGLq7Zo`Z3 zEoB(y%-G&+E}d9O!i!-AAm0Xk;~R1F&vE0oHtk1uLe#W^p(DAfFNA2dnr2hqhxci^1lY%cTM z{wn9tlPWIHs2W+oG3sxK#DAi%?Yb*^ue3SYLO1!9F^IF=U+&TVuJglbIYE4^%Ah0i zRz;8N5?Ko;1f!{Qm7XxjKIq&D)etnk zIMRJAt?G0*bM@)uIcar*$I1&n60?FhUYmZG$F)=B0uIkTb_F-*)wn}8l7#g%Nyw|X z$nFThJrZ}L1P%k@itq&lYm>Iiq zoImYxGzPolT&DG=^Dd`8!6nv~57*^_uzTA#eXe_$lU$@P(sHcLB+B}v{|J?$96lJD zcHi$UI~(-~@jd&IG3mKItR|QdmH4k|QRcERcvzIVphisa5ueAx7nlQ7b!beWVZ<0= z%d#^?8dPL**0KpV;U46^>k$4%j)HZ#RJ8f8GMLDqe02LnqvmILNK`zXmejkK2i(Fr z-@g88N1)WwczVrOBPR8#X0^za=rIxh@7Yl)>5-)$+bBtDIi}KVIqt`0ugS z9s~s9mJbpykx8j2!Ab4@4;H^PB6II{uM+aQh|W1+x8bn2waqUfOsl94p0+;@u^xCc zg5Pt5NIA-DGCT5Vl%vW`yT)6g&HmP}hTST}es;~fdkq}v#0>xR(eF-;>`W9mpUotz z;@IxjB(JX3Uu`GJuh~tVZSR?`w>!Y5imUfv2SvBaEK=h0`{e6Or0iM}Wv=G2o3u}3 z*Zp^hfZF36`FmiBC=%A;jOQq16;-|_HXv1U*EKYg7}oy zt&o+Tw)exc3N9AOhqkKq%O-)>F<-cCIO55PLOUQap@k)O%Zmfd%&Or+^c;4h*B8py zYz{{gx2}(W9qjKcCAF4v8E+_5d)sxcTvy89E$L&kyn{>m-0a9cTTX_iOU=x%F^qmo z39R47mer1wojtFy2ne*ey1Gq_HXbbjcvouvQ(@zed9*+LafqFigfL)_d(}n^!+&Q) zLH$N@ebRvJ4Byf;_%dteeg3roVWeg3?yRfn6*tPe4ssKhi<4axbo^fWpZ)||{1^_En53CJw5fI%!-M*K+19eOD5E z-(FSLZhEv(j9fgCjf)L+UK*2>+Krv01m2YNTo_ZQZ8zOEwC|I>)tl`l$-cAwhV*K(U5LMPC8 z;{MRoD|5TS{nD!$BzV+kJlM_hdg4Ly@n3VJXyDq&Q1Vfn04OEwgc(%yFK$zT#Nq$q z63r7{w&H)&6Qez%=TXgWGYMFigsc~CGZtvoIuB&XfQ-Gn+`xvkfm;s-vIAfEV5zFx z$AA5?A#gmgKd2x#+0=JKv*|Vg2tEW!F>qZ=zSddDeYD|J@nH8^bZy7S;^=qI9-i7h zwjMUE`esBbot1S*X~M6lvYb_dOIT@NX55d~%H)UU42?v$9}E%EpvpQc6**gcZ|g@? z2Bat^2~e6zD~b)fskjiQYg@r*+U=2Qv%XL)G^l$6@zg)1h2BBg8ZR}|c3w^Q zA#;A2Z|(WP4rCYk<1?P!7e}`pS`9$^bXt6hZf_s@V-(rYw616}S4Ni5crurXO_6Tq zpl+91v)Sfeq`>H~6Ow1t|50$1!hXcRPmJ=p39^u8DF0`XQ3Yd%o-uI+8(c83<>pk1 zRLv=mpBj;8$SB^TeD;eF;@AqXrH7_+@lUNl#+UmXZc@c&8BAobdr4%bU$V-V>uVPd z3X^|XIs=plM#e&HNgP`cy7`bjw#^L%@S7nsjMHhGG3-Vl^D*AxK;Cm}s`lKQK+AD4 zLK&=Z`*p{)a!JPVAcHb5EzTHuypsv4S8#lKk-fIZB(*Agw*rAu=3}0MNuY z&bu_NXx=yHmDISMA4SkB6AOI|IZ^T%Z6(+Q`~&HbWr?pZsY`?LlP=`_awa@lVPYrBJ>ukHxx(9hSL~w$X}mEA zq+$VqVDoNJ^SK-`T-Fr5efQpbl!8|G!T4Z_Vx(JBRsG6C8vbx(MI%y!k|b<5V;2EF z?OiyaHCFTa2|n^|UIdDra2$Hs=8$-gV4%pxJaK!@4^4G~`D>Pu9o-!+b=JhKa~VQEKOH&@@PkaV2)ZVwPXoOe2x9?~=15uokNLv%7FF z=p{{M5L;DgQEUPSOTKs^$f7|ximNv2NgOJU_*Bwtu&zvIc-5tOw1ux`18D#eQ(+-E z;dvSU0Ck~&g4}@!Dg4!FPgJ0AdC5Rvq;V6V(oBh9d%i>VFW5z zcC*~?NErl={uY2o;b~GR`M z;#69QjQ>irHfot&N6u#1NykLtNXz6qOQXj=B(oROPzH7c8dh{9^fizJDsc5ADP9Z`1n zWMLzfYio+Y)`B9z+Q3}uwaePzXMlXoR03In2qQ#aV`pHP;C092`$?}4gP8sRr9p7S z-$Cg}na4^vv&sgfwTJY!03578>$1F}4}8~@S2abWdQENCA|jIBBePzudUFf^axd_A$wG7|jnT zz)EFcSBjjxoTPS69-|86hG)J^*)EB()sc^B4(A@W{z@pzkhWs!M(c%8U|Q275c<6i zkBW=E9r^<4gQG;(!=Vx5Q$Vqaf%|%it{0GH(cg=O8TEaQAIun&bnS243LO#bEA8G~F$lIGna00a^ z7nv`ioa;((dQyG_cVjeGvl9|ABu&X8ee)Og_1CX6MW8)vc7F2zg=Q-tr1f>Sb`rl- z98JyD!M@L{5x(!sbnfngfAQgVHihJvy3S)m|IJK;p)Z>o0U};Xlz=Ryu8_e`_!xjz zAy{w*gV6Q95TENwYo*Qd)mnkz)k0W(<(yx~_2#rYzu9oEx@E1XMe@%n?IPDVQkDJ4 z8zSOhl0iBzdTFmy8*xMe5f)nj4UktprB-t?H+^CFS*&z%k$z>_(%r^yxHazbE7%k| zrTmfwhHZ105DHvSff^dhIgqll>I2QC?e28ap?y`qoOJ#8V1KV8YJj-Xq>Rno+FpG* zJCf_@*=?t|_ZLq$%U!lCV>#tJpnS+m`jEE-g|bSszY zgq>d%vR7xHw4V(g^&)Wq&Au`wEGM}AtiryYO87hAPb@tL;EvwBWNmmrDDaoA%rQ+e zc#NH0O+KY4MTl#%^)i0am~ zlfPv@XZ7ImV&j02fGzV7&#=_eFpKEh3-V8o+`UUk@jSP|0OfiNuqYCGsWx~jkUDn3 zx9rMNQU5?W+$%H%Y+`H!V~7%XpeunJ(uB$b->h(W_#WJvi8RsbiCUSBw&KVVpictW zZ&f)&eN>o;*pu`i6eTZ;f$F6AJsXFoh_#tH{052OKdgdgJ7f;+qaFpnpcInzhyM;< zpl(2~w1b0B3V%_v{)Ft{4EmZ7P_hhPga{k{ty)<*A+;o{M2aD-voKLka?2q^WFnatTe z8Qdkzl;?PWA=wO(rzuAmA)5=T{<4s{)j)&7J&^;{Sw}4m$@&-+c3{G>0j~Y6&JfwH z^cQV}!fOk!#+8$v7GR8#74(C8BtG3_|? z*vQ+^yW}oP|GzQ9yAkLA72$&?MTzjio1+iAzhrX5yeuzSap=Wa&626BeWO631c_f+ zS+Oeqxr4I31uS9kU~1|@9>Vhk4qW_SIpiUmJ&C3hd7`3Cpnf#vC>N|$`c^N3#>hb$ zE#nxql^Ey}A18Tyz*oZ4rW(GDocdTyi_Gb&blJtnWvM$6#y)}TKtt(Xq(-HuO_cP6 zK7X)d(9rPFGWll@lY#>2SDa#I&Q&GZejUHHdvhN@J#eRU>6g}%=c<<$MM=`3*=7A4 zF{~|V#LKmwd_!0lNyzg2Oub;B#~)woQ9hb_hoA?D>|UR)pZ#>~&gSv@na!b48;LC! zyAIJg+)p|~o>?)D=-0ze`DFu*m~W;5DcbW^G4(}~>#H%u>ENSmi%2umsxS2Q-JZSF zq&3tuxlF&wHB|r?99pi8zR3ggyl}(7(ZzmuW)kxyYra!%@;?21L6E@C3HCTS-^oOy zjmudbqh&8<5UsXr&RKiL=u~#@zO@o(GpHu%4;dv><0-1GzejTNs(LKb0u0nW@qvMQ zSebpx*Da#rnR&PQ_{FwE5H|UJAhKx1PbxpnJGDLty}>WVk3Gz&-Jg!BawhbrKe|+8 zemM7S4nKxSHx{vj6u#Hmsz239Q(Dqhg&79SW-H-g*e4Bn9MO#0N~}&6Mb@X;pl#n=bPJs~ zgx23D|A}XOU$xqMsDFj>xx~0w$68%c^v!a*^_mB70(Xn^q^ga!!&>O7i5D%!_|y6(j9 zr(3Tu-n_xHv-c0X)1Jt|?i?Nn)FNz8u4x3pcfq36}iHE*V#xigpT-o~CbwpA6Vqd zI|@J&X8O&J8JyQ*I{-xpyjW}b7p^bOdT8+{`iu?GQOh8AvHu$Y{drl96c-N?PQ+=UYs$JQ<4LL3PR`)3}}JZ=dE8sZ9yP%Km>SjnI(Bp@XA4@%yka{G+{YX^miKNtHk--|=1_<=! z;_DZ3>R)szhb|QY8g9#nM_J`FZn^tG**DIu+{* z|6MV8KV>+%s`<+O^YqEp!&~Pj|NHRmx&OzOovV9wzzG8VdqBBWO*nsXkV+X9haKeh<(ewN`qEt92 zw`4vnb#jQ%m?+}|K6`eu;^Kvzl7)of^dy_w+W>9Ma@=$v(BwJ!tIfb}Pd`)Dn)-GB zj2HXKh3EN`6~MLrv&&S9fUl^_)MYhgM|5SRe|9;5lKmJ-oq)#(j{K+4+ zS@r-CdzbnE2ZAuFygBV=y<|ZN*&K=~WeFGLi_5D?G~RS3-%wK%Vg%0Nd<)Q%nkX%2 z=V~;&>o6|C*+Kf#Lz%O;xOVG87S|nRj1Y+I)t?qE`AR0N1|?o`cRjq{O$YnkWP`oS zBUz*vAMe`B8VE}oDx{_&urYXTsKY`fuIz@JB^0e0Aj7|MZK!|4ZliSRlyV;igYpBU zAs^@hC_?x1C=S?c*3c7_v_ZL12zgVqd^vy%dpKIs8DFw3CSJi8B8~aD4XK(w#l$k- zo2vZfKL5SBvkez=1NoesU9|m=GUpN;`ZQr)Zre%EaTPbvDlhSxnifWLKhfyip|#%M zqOnS>-DYBN)yi*p^J1jE{eb3&2y`jS6vSqTs9FvvO5hC>fRcBf?n03e`~N1O{GR?W7^GBIQe&m#^M*=jG*NJfIs=nfcc zcU(Qxx|TIy?uGPNTBnp&uT;P6TUZuExf<>8a(%HO$Q&>-61KUVi9|G3S^d+qfn@B3 z8zNhKwvL3n%Eh~m9w@mCoezN1y=bu~6_G7tK$)YT3ZH1_~> zLzOD9r>vBGi&+=BI}Wn2o_3$ zRMxvs|5;x=n2ED8S(K?yTToMtnD9B3?VEt(8eQXsJ8#Iqk-4SP>n`zzva9}jm}tTB zWcDc^iScyzl@_D&H7?G=a)A80OIQEyrrJ6HR3F@pO%URG(IeE|+It0|U|(HW`1@wX zYunxAV`JmA92rqV5!-bpopiuP3aMt97~sS2yLeo&`+O@%p~_6qG4vk}{9`bw;`Y6= zuWy=Hqyh(B9Dn3>ZN|`2x^*4q6)Br0x*uoatb;rH)5+2=<>Kg$=7cFL^^SyASl>Lh zhQ$$OkzTR!K9=P4KFlzmJJ@3KX>jM?UB;P6#>v_C*a3+jn2N%ym*$G#s#zpwv@tdV zWirExGL$ zHFa2=OW1aYY*nNCb~aa0w$=Xf*3ek9POhfy0OuiohSFVsY~F?t6c3pr(2|_+{Nvog zZ!PnUhUYTX=eU(elN~f4z7m&p*yaTGWxGO+F&ow+e!_(wj$5(F=0%2W2}?-hE0d{D z=S8(>;%8+ci?7VgoLXdDwnvE`yKBotvWn|y{Fi^q;4jNaTo$)-SQ6(Ud)KmiJVlyP z(fRh_6X%?r*52RPk|kbn?+=m)4ydX$8(#`}!9v1mkUR zw4#IyU|!!)637vSzd}Ut*M^+qtm#6^(;9%zoaVO(Id^r*FZ}r6i1T=SMf9 zahnaM@u;{}{;WUE0eNU45|QYS&dq%}Aft7;x%fR~o(}8mu{{XbFxyEO*t4!(*ELvn z+4aCFc9#5g5MC-sWH}5gyE!qL5PoI!6oD*~&T@9^>6eBhfiE@j=kOL;NW5h`seQat zLY(g!wq<^;Q1nA2Qp0322VJUAk)3(=e3j{YTA#4_P5F&hp=-cKm$sxppxHi^#V_%d zGpWnCHD2AnIaW&((J zY@W|VM@No1_Lovdv0R)xzaV!1Zn^CJ6~Xmu0-LB?oH^Ij6MG-PD}kpXHEBTaycx&sgyQF;Vq-9BxcHC4L))${Et#@b z)hojvN;cNi+((p&wq+ECw45tSjuNIMIRr80Ha(>qmaQ;sm;>I~e{&@_;_)~STC-0x zQp#jJCnu*AV{#BJ;nM&wl7?iy-L8+;8TvRzG|2we9PcXXxPU4P2?S9a(vn0MBs?1dUw_iGkA)O5m?#d7i7lB>a$aJq3UVLGKEfl4|89g zqj@!CmM&&`{*5c|z?*|lS?2gI%5B~uG0_F$T&725OMKakF+CB9alX@frIJCd!WSPE zUl_g1A?DFaa99h_ojLWU8O90%S%_)FXhAjSo}`(eIob*HZ`xwAlhAzV*_JoP{B4M> z{*WcCPsq{3&LHUvdJkL+jc}GhuntJ{8$e@qb(f?sRJ3v{|B->17yv%Ee8P5X2c=h} zStRgn%NO0hb!^prnj=WF1?Tokxl&V53%?735PJqZ=XTump-|QL?SbF^COt&}Kjv z4No*A$hZ#8kHrWI3YI4-XxOP4(o>a7kgMphSkFXdbRBCtCh92mGbHJ&s_jLQx%SL= zuf0+ppas!HL&s6`wk~NRFX)g43T?^{{l+GGo@=iKRGA@Gu|uh?K|ODsMM zD(G%kDw}z`KX!JeM6(!X*i#oRXuOG1C7OtBv(ry2`>oBtk3TBWdKxCyl&VoCS42pf z>Yg?>-%8N6_pY2QazcobmR83#a(+Ab*~zQ09e%KCC$`o$FBW+0!odfB$(pe-eog4* z(dwdRF$j1hEi){lq$m?(XJ zwF1a0)b3Ctt67i1Cb5rY>A3uX+wkJQYONCI24Wkcr$pJa>Ck1Pq};JPNkk(|IObJf zspJbT5`VUFHl3jKh7vv3p)GyFK{9kb`hnxJlQN&9W0dJ8g8+7MICN&wEstQ>C2Q6h zF-XMwTltBYdN$HpYv;eJjVa?OIVRzbaVqfj?oiDH=weZ=en3V)+chAS&tW=2821?B zzA6DCG#g9tFm{*}qyNANcfqEnt1HkQwW=9;-m7v{1WN_#(We-3Ja*aRXOY=RH}+lj zmoE;UCP_SlBfdH4ix%naw9q=sq zVxtMBQF%bJ?YVm5M(da#_%R%i+E`?cXfZrTy>(N~0@_H06e3H%lu3`>QD!{-WnS!a zL>{(XGsQ@D^*VjRk-suQHCVan{Pc6x?2hi#!Gy}jk;B$XoTFkr1OJaHOAF~8=RNM= z&bpo{E0u$VNJ@{DoARCEprFm6<)C*70HT{X!N#{fUru-GCWdI6IQ&B_U1WD$zUf$z z=i$osacB6t6x!5@Ur`lmS-9eNvmNF;Q4pHVGLR&M>h1V;Ozv7L8r}Ow#@VMC`$neN zZ<=&poSi+iHot^G3lR)Q%SPf4mRJQdl*(^%C|;vRX|}UeRx|gWAOE_4O!U4pYHwn3 zMu@w+l(Dny+^IglJCuPm!Y?b>PQ#|^b;ia!I(|Ik^0hf}gM+96(Vyl5R|<2IB3$*X_W+^{S)8|!rxaGhUQg+`9AhTbl602lf2CsE?^ z2*;~yLRDI5o}nh85hr{5mBXJX~2`7%<=%uY0w(Vj6S(HOfICJ1p(fY^}IDJyrjwcIQf zi+=8m$OBm-lP7i0QYpEwD>W1rc6>@>tnm_OiPQ>L!pe_M2Al1<^P4Zyao>C`MZjy7 zoqzw9oV8(=h9Yp3e>S8}88iHd%NVE2?p(kU=9DD%f>3%dR6^*7T7u17_w&&L1}O!&Mt||vgOq%!l)$#PitUI z+;{9r$3Y7gn=4G33A%xgi_VV&mg4uE3OFVjX7axZ(lZ`lTpCKSc6`A%*{XwY(%Ldd z{b)@u%;f{w`(jDR*j!)d68un@_Gpt@LGsMpdc&C z`bw-dkROG^k@mZ4{cH>iRS$u70Nh~=W`R^PjP>C3E zW?S=x{)jC(IK(yNn?aj#I`gnrwf$_rxhY*FT#TGSINq`aFBV2pijX3rj*{U`n%5e7 zq9sHywwegEmdn&n(X@Fn5e*E?P~H$64IJt|^M5-ry*>AV^AX&odpO93TyT7s7Gx)> zsy~(_36Cm=@a>9-iC-HsFCY70wNaPB+=e7Qh8GtnRJV<+VZb@6d4$?)5G6i7P1>pX z;US5lxj8e$+b)=!ai{Tgu~s?OSk4e8DRmt%`TK0t?F!jf5=vS8i9Dn7>(^T(g6vK()3=27y5s@ZfA<+y0D)NC zrCO6j7L)F|!Fa!3IeNI1kCBZJP3|PZtK(b9(AqDbrNt=KcY^Z;GyEO6%JCxFeahfs zLBg;I#ajxv=_d`qBVmxnCNu46CKh6Eu%0?M0ip{EiUFeET;?p$M-sd>4~9NB%HDtc z(#XnJ6OjiiCl@(Cc{LU~mTH=0V2bF#p^HwrP*jlE?Fs-xS`8(IcS0=8Rh!;;ODrA9 z+Q;fd6~}ZoM8_8-_WWqJw2m`71FA`rr}o3oK%%5QFs(UWTez__uOxw!qW}QP;8)WdPLGaQ>Nx?^6&+EXvxb zu}*;tyRu?Hj}Udlzxq(NXkvh`GpRDHZIxB%6I7@U3*8qFd3Z7B5ym`(Q#b$Ep;>aP zX6$8bW;r4@iWIc0q{yENY`V43CE$(LPQq
    |Y>E2bu{6+8}P5X8vP$4YZs8|F$hj&$GRxzplxP{^)4?5fk$j zdqAMKdg>pV>Pl8N*A}A#1UwX>^@H>614t@qzao})VjEOCitkC4)xn0g<0`mg`tRk< zoqqcTwLF0qVP)4YP(1L!0&c}Rf{echVOiiy>Hf^bW$b%3fL-}Nim9NxYcICYM4=3P zJk$DYwkfNz&v-cb%((X|wjHx!?5&Ny@Ne^T4V?hOD~}%?CFbxMuEc*sGIQ(?tIt95 z98+>J<73z@FqBkKr%4tG$_e!S)jQ7Y=GJC(G$`(2h~-y@<~m=8QhMsYCg1RIe16PQU$vUd!m_$VJYxx^v)}TMl7Z;R$K6Zk>9QNs;tx7Gx5_vVm zqVyJgMH5O5(U8pH7jgcbpo#(erWi8)4C3*>+`faspb9zv8vXeo2nYA$MOzIjRGbLl zQ};zkZ`{n6mYu$XRKCL3!AjmHG$)B*2&s=7a7 zNteXjQIOqYpKK$BovYsw;rq7##%e?~jt($Xd;l7wEU9?SZ}x?1l3x`GEoCq!SX59^ z*Qi1YWm7n^*bRR&=vzyqTh$YF_e`lKN14egMx+wt5P8NCu&=<}E>=868xX;Gq_;RDALrNI#V64SVH^-WiJy*}L~KINJHI372@LB7}u1!AI@sMWDv5 zyKj{K&!;*FcKsvk8hr{hQ)1Ck`~ByhW;}X5|8QeWgHVkUXqp-pk1N(L-CJnB(Zapk z+uNIuPj})ABsoCU-}oF?u&5mNOZKTQ`_Tbpm6(I0#=H?0{kL|aBcv+kq|z2rTdY*p zUC&`)pO9>XJ>qi0IiLo$kGG|F!A+mCPh8~jzbBAVu*^th_jVR!Yj#BfNeZ&U10wIM zM==|W$_c65b6J&s&+0OUYizlOzj_WVU_giUfcKhU7Ka=0KKob5%$=j6tb*+RjvBOb zf#I-59CDoS186Lzj!8Nh{VPk62KAVZQ=Tv@X;0^!iblF%l{$sno$GhM$ zCkN zhw6wLBOjofl{?y+A5Mh|(%fwIk)V9;E{U;jvR5BAld6z04tMhm%bwc&iqc?G7b0bau~kER z-puVGm(Oe{(rcms${0!UfyGtT|4JZTud5BQ{S+CJP@zRjdPTroV;2pz?0JiI{9*M! z#nFSLra$}|qM^o!pV>Nf@E018F=3J>{WXW0hcD#~{)d+4)9%=P890s8aK<9*VGwPy z-EVOe+gf$Jg49p&^KgqsvAN`V>7jk1kzLL){)ByP$xC?fD9BbE5-5iJ9R%U~IfaS0 zP%3TgD_FgM(|7pAyz!mv^BRBUbg%s>vKB^e&n|nRC9m&kQEs17zh}Cw&}IN;56Fz2 zRj6)SQ@qyRIXJX$t%#_^nsBbF9`^z?WiclQXj&*CKotJEsrl}1?S!}3N$})YZ|C&q zD%$U}vO@n*LiWr50j4zNcqs*C^&mBivZU~tt~9L=MGD0{ddYI}zp_1lj)FyJ1;ZYr zGK4KI$nG?UFUD8ge-i++Le51p<1oQS0j0$JpGM9-p6UIMX>-~JafYb2D0G7X$Dd)SVfPooBrXkLR;@g)Hz=nK5NEp>dLy*3T z%jGwQ9v)sC@af3_?*kxI95NoGn}||S!Wr-GJmUFy-XZlp76v%be7&h*^#odn zxcuEx{bQvMa$#(5K}t=b0uaUT1-WwrxNe0InPmLs{oR8`=HB-^f(^+-TK}hjf zlqOJQDs`x!3k?87VEs}v^8ItT4N+)y*K%Ww*&Y_&(OK-FW6DT_$dObz<(iUOC6>@R zv~jmhW^|Gu(D~%-UHt6$*5^q~i5; z2=}(?ciHDXYewKx>D&@ixpvob(*iURxy&Z$+3qfgMJ%j8lu(oUvdeic;W`y;QfA_f zP^5HelqwC8)$pTpnTh|E+`o3{KT{8VD;#aaT`&XO6%VRWznrY7< zvjExuPM*qMWw@30*40Q#HB(qn-&nuP%tvQ?ra5%F5r2@au}%wuf4FxW+<}KKauk4r zcr)MY3XSaqs?_K!#h-5>1&S1*v`?QRk^)*ZFw69J;a!^9yvyh`-k3Zr?rG6pD6Ie~ za1+NUeF?+hu1i@iMrfX+SSEtq`<=&wxXK}b5AjNtMSa9ZU9^Cc!&yr`%ehO1>eX{1 zbn}YdZj=6I&CV(_(OXD;8{FV z{P2MOW`AQhrvRoC?*`_7I0#;9;t3Lo1a_2X;L3;rLFd~!&|bja^`%7iKF)zirawU9 z{Z5q{+P9BXU+CnYfB-q1gM9A4YZJ3QP);Fcq1PHzpaWE3+h4SB5Pa7*Gk zJ{|*_dhZeTMA7r(?ugv&zwd4p8Wn6^7%C(awSNn>et#IZ%gzj*BRLE`2zL{L4QQ|X zSx8VBW&`D!9B7TKeDhtV#jII6=UBBvDkow>HyJedmM$mvhhxwlhlVPyoq?iIP_e~j zv~=9~1XiqxInZDqf|hw^spIT2F>+0IfB;usQiKH254>97jZdgg+l7zQc&boLAZKr% zacZ&5p!Xe&PCj-FSN-^d!CN`-Swpr_t1vDPWb+B5glPzAZn$YHTMnP|zZlj(p8w79 zh2afW{em!d^NU7MnN(o0<)mZzfxlai5^GDQ6K;H7VrWv(SH#4g?H{kbDrzSbWD8Q& z*fa!vAtOOZjzbGaK-;8>I~?O1d~y3bn7RLRD-i zlpW@!RT1#EdTfuU`R-)Pu#FB#FB}y1jTFw5aqL7k=wZB1iJ_x(GuP6Uhm15~u3(pC zj*u;Sm}g?b*`t}P?$WPCSU|xk$#V&scb`f2Z zvEV0iZ#5pW6OgH6iSdSaaDfd5H^SM6GUj>boJ8$pv{^9^UXlvj-z*vd&*tLv0e1z3 zNQCy6<884m(Vr5cYxshdjIM+WadH*DANcqk&ZHbKD(e6$@1ge~agYPX*YxKzc<~KGJlfuWT&Ke$I(0YOyLp?dFFzhpvdOCI!MLof-w|8^G^8*uJ z-&sBt3S`O_-A6*|%uAe3|J=q_S+pr>L348Kmfm;n91NU^%{Mu;(AhH+_&dmayH2MA z10+6hC|T)lep0q553E=@s4e(8EB&h^K3RA|e|o=O;yWL0)Fz8CTSn3YHn#Z|pB zz0B-+%x+>`_WM2^4~5fJ!FS4HUNQ_$EVnExl05@Q9aGf>39@l|oJ=w|LwT4)HFk$a z=K2-I_S+X`54(~^l66c71O(X|T6yHoqTVJd>MB{G^9Jk9{R1|a+|`>*bkhq7W8 zkxu^SSD}TtvmV{%U?$d)%ttHP&LybMvIA#aed|8AnZz|BtOw^` z*Q~;o!RQT}EYa-7?7RdGq+=mkiX2Gv*(}Wi$90OUZez5!Y}HD-ZbL0_zfD z9|9UV!ofu4xUD!LeOC3~Li%2p{5{SM<+y@LPEd-|7{&&#wQk6m3d*{cha`cpSuQR! MBdenn%(>7%0T;jih5!Hn literal 0 HcmV?d00001 diff --git a/deployments/docker-compose/.env.catalogs.sample b/deployments/docker-compose/.env.catalogs.sample new file mode 100644 index 00000000..f0a81187 --- /dev/null +++ b/deployments/docker-compose/.env.catalogs.sample @@ -0,0 +1,2 @@ +PostgresOptions__ConnectionString=Server=postgres;Port=5432;Database=FoodDelivery.Services.Catalogs;User Id=postgres;Password=postgres;Include Error Detail=true +PostgresOptions__UseInMemory=false \ No newline at end of file diff --git a/deployments/docker-compose/.env.cutomers.sample b/deployments/docker-compose/.env.cutomers.sample new file mode 100644 index 00000000..55fe92a5 --- /dev/null +++ b/deployments/docker-compose/.env.cutomers.sample @@ -0,0 +1,2 @@ +PostgresOptions__ConnectionString=Server=postgres;Port=5432;Database=FoodDelivery.Services.Customers;User Id=postgres;Password=postgres;Include Error Detail=true +PostgresOptions__UseInMemory=false diff --git a/deployments/docker-compose/.env.dev.sample b/deployments/docker-compose/.env.dev.sample new file mode 100644 index 00000000..e69de29b diff --git a/deployments/docker-compose/.env.identity.sample b/deployments/docker-compose/.env.identity.sample new file mode 100644 index 00000000..7048dd10 --- /dev/null +++ b/deployments/docker-compose/.env.identity.sample @@ -0,0 +1,2 @@ +PostgresOptions__ConnectionString=Server=postgres;Port=5432;Database=FoodDelivery.Services.Identity;User Id=postgres;Password=postgres;Include Error Detail=true +PostgresOptions__UseInMemory=false diff --git a/deployments/docker-compose/.env.prod b/deployments/docker-compose/.env.prod new file mode 100644 index 00000000..7ad9e380 --- /dev/null +++ b/deployments/docker-compose/.env.prod @@ -0,0 +1,2 @@ +#https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file +#https://docs.docker.com/compose/environment-variables/env-file/ \ No newline at end of file diff --git a/deployments/docker-compose/.env.prod.sample b/deployments/docker-compose/.env.prod.sample new file mode 100644 index 00000000..e69de29b diff --git a/deployments/docker-compose/.env.sample b/deployments/docker-compose/.env.sample new file mode 100644 index 00000000..c1c3f0aa --- /dev/null +++ b/deployments/docker-compose/.env.sample @@ -0,0 +1,46 @@ +# This needs to be configured by each developer based on their platform, we also ask people to tell git to ignore changes to the file by running `git update-index --assume-unchanged .env` +# https://docs.docker.com/compose/environment-variables/env-file/ + +TAG=latest +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +MONGO_PORT=27017 +MONGO_USER=admin +MONGO_PASS=admin +GATEWAY_HTTP_PORT=3000 +GATEWAY_HTTPS_PORT=3001 +CATALOGS_HTTP_SVC_PORT=4000 +CATALOGS_HTTPs_SVC_PORT=4001 +CUSTOMERS_HTTP_SVC_PORT=8000 +CUSTOMERS_HTTPs_SVC_PORT=8001 +ORDERS_HTTP_SVC_PORT=5000 +ORDERS_HTTPs_SVC_PORT=5001 +IDENTITY_HTTP_SVC_PORT=7000 +IDENTITY_HTTPS_SVC_PORT=7001 +ASPNETCORE_ENVIRONMENT=docker +REGISTRY=ghcr.io +PROJECT_NAME=mehdihadeli/food-delivery-microservices +#https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell +#https://github.com/docker/compose/issues/5089 +#https://docs.docker.com/compose/environment-variables/env-file/#parameter-expansion +HOME=${HOME} +DEV_CERT_PASSWORD=${DEV_CERT_PASSWORD:-123456} +ConfigurationFolder=config-files/ +GRAFANA_PORT=3000 +GRAFANA_HOST_PORT=3000 +PROMETHEUS_PORT=9090 +PROMETHEUS_HOST_PORT=9090 +KIBANA_PORT=5601 +KIBANA_HOST_PORT=5601 +ELASTIC_PORT=9200 +ELASTIC_HOST_PORT=9200 +EVENTSTORE_PORT=2113 +EVENTSTORE_HOST_PORT=2113 +POSTGRES_PORT=5432 +POSTGRES_HOST_PORT=5432 +RABBITMQ_PORT=5672 +RABBITMQ_HOST_PORT=5672 +RABBITMQ_API_PORT=15672 +RABBITMQ_HOST_API_PORT=15672 +MONGO_PORT=27017 +MONGO_HOST_PORT=27017 diff --git a/deployments/docker-compose/docker-compose.infrastructure.yaml b/deployments/docker-compose/docker-compose.infrastructure.yaml index 607c1944..e5085f6d 100644 --- a/deployments/docker-compose/docker-compose.infrastructure.yaml +++ b/deployments/docker-compose/docker-compose.infrastructure.yaml @@ -1,4 +1,5 @@ version: "3.8" +name: food-delivery-microservices services: ####################################################### @@ -9,18 +10,18 @@ services: container_name: rabbitmq restart: on-failure ports: - - 5672:5672 - - 15672:15672 + - ${RABBITMQ_HOST_PORT:-5672}:${RABBITMQ_PORT:-5672} + - ${RABBITMQ_HOST_API_PORT:-15672}:${RABBITMQ_API_PORT:-15672} # volumes: # - rabbitmq:/var/lib/rabbitmq networks: - - ecommerce + - food-delivery ####################################################### # mongo ####################################################### mongo: - image: mongo + image: mongo:latest container_name: mongo restart: on-failure # https://docs.docker.com/compose/environment-variables/env-file/#parameter-expansion @@ -28,9 +29,9 @@ services: - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER:-admin} - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS:-admin} ports: - - ${MONGO_PORT:-27017}:${MONGO_PORT:-27017} + - ${MONGO_HOST_PORT:-27017}:${MONGO_PORT:-27017} networks: - - ecommerce + - food-delivery ####################################################### # postgres @@ -40,230 +41,232 @@ services: container_name: postgres restart: on-failure ports: - - "5432:5432" + - ${POSTGRES_HOST_PORT:-5432}:${POSTGRES_PORT:-5432} #https://docs.docker.com/compose/environment-variables/env-file/#parameter-expansion environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} networks: - - ecommerce + - food-delivery - ####################################################### - # eventstore-db - ####################################################### - # https://developers.eventstore.com/server/v21.10/installation.html#insecure-single-node - # https://hub.docker.com/r/eventstore/eventstore/tags - # https://stackoverflow.com/questions/65272764/ports-are-not-available-listen-tcp-0-0-0-0-50070-bind-an-attempt-was-made-to - - eventstore: - image: eventstore/eventstore:latest - container_name: eventstore - restart: on-failure - environment: - - EVENTSTORE_CLUSTER_SIZE=1 - - EVENTSTORE_RUN_PROJECTIONS=All - - EVENTSTORE_START_STANDARD_PROJECTIONS=false - - EVENTSTORE_EXT_TCP_PORT=1113 - - EVENTSTORE_HTTP_PORT=2113 - - EVENTSTORE_INSECURE=true - - EVENTSTORE_ENABLE_EXTERNAL_TCP=true - - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true - - EVENTSTORE_MEM_DB=true - ports: - - "1113:1113" - - "2113:2113" - volumes: - - type: volume - source: eventstore-volume-data - target: /var/lib/eventstore - - type: volume - source: eventstore-volume-logs - target: /var/log/eventstore - networks: - - ecommerce + # ####################################################### + # # eventstore-db + # ####################################################### + # # https://developers.eventstore.com/server/v21.10/installation.html#insecure-single-node + # # https://hub.docker.com/r/eventstore/eventstore/tags + # # https://stackoverflow.com/questions/65272764/ports-are-not-available-listen-tcp-0-0-0-0-50070-bind-an-attempt-was-made-to + # # EVENTSTORE_MEM_DB=true, it tells the EventStoreDB container to use an in-memory database, which means that any data stored in EventStoreDB will not be persisted between container restarts. Once the container is stopped or restarted, all data will be lost. + # eventstore: + # image: eventstore/eventstore:latest + # container_name: eventstore + # restart: on-failure + # environment: + # - EVENTSTORE_CLUSTER_SIZE=1 + # - EVENTSTORE_RUN_PROJECTIONS=All + # - EVENTSTORE_START_STANDARD_PROJECTIONS=false + # - EVENTSTORE_EXT_TCP_PORT=1113 + # - EVENTSTORE_HTTP_PORT=2113 + # - EVENTSTORE_INSECURE=true + # - EVENTSTORE_ENABLE_EXTERNAL_TCP=true + # - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true + # - EVENTSTORE_MEM_DB=true + # ports: + # - "1113:1113" + # - ${EVENTSTORE_HOST_PORT:-2113}:${EVENTSTORE_PORT:-2113} + # volumes: + # - type: volume + # source: eventstore-volume-data + # target: /var/lib/eventstore + # - type: volume + # source: eventstore-volume-logs + # target: /var/log/eventstore + # networks: + # - food-delivery # ####################################################### # # Redis # ####################################################### + ## https://developer.redis.com/howtos/quick-start + ## redis-stack is a image with redis modules enabled like JSON module # redis: - # image: redis + # image: redis/redis-stack:latest # container_name: redis # restart: unless-stopped # networks: - # - ecommerce + # - food-delivery # ports: # - 6379:6379 - ####################################################### - # Portainer - ####################################################### - # https://bobcares.com/blog/install-portainer-docker-compose/ - portainer: - image: portainer/portainer-ce:latest - container_name: portainer - restart: unless-stopped - security_opt: - - no-new-privileges:true - volumes: - - /etc/localtime:/etc/localtime:ro - - /var/run/docker.sock:/var/run/docker.sock:ro - - ./portainer-data:/data - ports: - - 9000:9000 - networks: - - ecommerce + # ####################################################### + # # Portainer + # ####################################################### + # # https://bobcares.com/blog/install-portainer-docker-compose/ + # portainer: + # image: portainer/portainer-ce:latest + # container_name: portainer + # restart: unless-stopped + # security_opt: + # - no-new-privileges:true + # volumes: + # - /etc/localtime:/etc/localtime:ro + # - /var/run/docker.sock:/var/run/docker.sock:ro + # - ./portainer-data:/data + # ports: + # - 9000:9000 + # networks: + # - food-delivery - ####################################################### - # elasticsearch - ####################################################### - elasticsearch: - container_name: elastic_search - restart: on-failure - image: elasticsearch:8.5.2 - environment: - - discovery.type=single-node - - bootstrap.memory_lock=true - - xpack.monitoring.enabled=true - - xpack.watcher.enabled=false - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - ulimits: - memlock: - soft: -1 - hard: -1 - volumes: - - elastic-data:/usr/share/elasticsearch/data - ports: - - "9200:9200" - - "9300:9300" - networks: - - ecommerce + # ####################################################### + # # elasticsearch + # ####################################################### + # elasticsearch: + # container_name: elastic_search + # restart: on-failure + # image: elasticsearch:8.13.1 + # environment: + # - discovery.type=single-node + # - bootstrap.memory_lock=true + # - xpack.monitoring.enabled=true + # - xpack.watcher.enabled=false + # - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + # ulimits: + # memlock: + # soft: -1 + # hard: -1 + # volumes: + # - elastic-data:/usr/share/elasticsearch/data + # ports: + # - ${ELASTIC_HOST_PORT:-9200}:${ELASTIC_PORT:-9200} + # - 9300:9300 + # networks: + # - food-delivery - ####################################################### - # kibana - ####################################################### - kibana: - image: kibana:8.5.2 - container_name: kibana - restart: on-failure - environment: - - ELASTICSEARCH_HOSTS=http://elastic_search:9200 - ports: - - "5601:5601" - networks: - - ecommerce - depends_on: - - elasticsearch + # ####################################################### + # # kibana + # ####################################################### + # kibana: + # image: kibana:8.13.1 + # container_name: kibana + # restart: on-failure + # environment: + # - ELASTICSEARCH_HOSTS=http://elastic_search:9200 + # ports: + # - ${KIBANA_HOST_PORT:-5601}:${KIBANA_PORT:-5601} + # networks: + # - food-delivery + # depends_on: + # - elasticsearch - jaeger: - container_name: jaeger - restart: on-failure - image: jaegertracing/all-in-one:latest - ports: - - "16686:16686" - - "14268:14268" - - "14250:14250" - networks: - - ecommerce + # jaeger: + # container_name: jaeger + # restart: on-failure + # image: jaegertracing/all-in-one:latest + # ports: + # - "16686:16686" + # - "14268:14268" + # - "14250:14250" + # networks: + # - food-delivery - ####################################################### - # zipkin - ####################################################### - zipkin: - image: openzipkin/zipkin:latest - restart: on-failure - container_name: zipkin - ports: - - "9411:9411" - networks: - - ecommerce + # ####################################################### + # # zipkin + # ####################################################### + # zipkin: + # image: openzipkin/zipkin:latest + # restart: on-failure + # container_name: zipkin + # ports: + # - "9411:9411" + # networks: + # - food-delivery - ####################################################### - # otel-collector - ####################################################### - otel-collector: - image: otel/opentelemetry-collector-contrib-dev:latest - command: ["--config=/etc/otel-collector-config.yaml", ""] - volumes: - - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ports: - - "1888:1888" # pprof extension - - "8888:8888" # Prometheus metrics exposed by the collector - - "8889:8889" # Prometheus exporter metrics - - "13133:13133" # health_check extension - - "4317:4317" # OTLP gRPC receiver - - "55679:55679" # zpages extension - depends_on: - - jaeger - - zipkin - networks: - - ecommerce + # ####################################################### + # # otel-collector + # ####################################################### + # otel-collector: + # image: otel/opentelemetry-collector-contrib-dev:latest + # command: ["--config=/etc/otel-collector-config.yaml", ""] + # volumes: + # - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + # ports: + # - "1888:1888" # pprof extension + # - "8888:8888" # Prometheus metrics exposed by the collector + # - "8889:8889" # Prometheus exporter metrics + # - "13133:13133" # health_check extension + # - "4317:4317" # OTLP gRPC receiver + # - "55679:55679" # zpages extension + # depends_on: + # - jaeger + # - zipkin + # networks: + # - food-delivery - ####################################################### - # prometheus - ####################################################### - prometheus: - image: prom/prometheus:latest - container_name: prometheus - restart: on-failure - user: root - ports: - - "9090:9090" - command: - - --config.file=/etc/prometheus/prometheus.yml - volumes: - - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - networks: - - ecommerce + # ####################################################### + # # prometheus + # ####################################################### + # prometheus: + # image: prom/prometheus:latest + # container_name: prometheus + # restart: on-failure + # user: root + # ports: + # - ${PROMETHEUS_HOST_PORT:-9090}:${PROMETHEUS_PORT:-9090} + # command: + # - --config.file=/etc/prometheus/prometheus.yml + # volumes: + # - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + # networks: + # - food-delivery - ####################################################### - # node_exporter - ####################################################### - node_exporter: - container_name: node_exporter - restart: on-failure - image: prom/node-exporter - ports: - - "9101:9100" - networks: - - ecommerce + # ####################################################### + # # node_exporter + # ####################################################### + # node_exporter: + # container_name: node_exporter + # restart: on-failure + # image: prom/node-exporter + # ports: + # - "9101:9100" + # networks: + # - food-delivery - ####################################################### - # grafana - ####################################################### - grafana: - container_name: grafana - restart: on-failure - image: grafana/grafana - ports: - - "3000:3000" - networks: - - ecommerce + # ####################################################### + # # grafana + # ####################################################### + # grafana: + # container_name: grafana + # restart: on-failure + # image: grafana/grafana:latest + # ports: + # - ${GRAFANA_HOST_PORT:-3000}:${GRAFANA_PORT:-3000} + # networks: + # - food-delivery - ####################################################### - # seq - ####################################################### - seq: - image: datalust/seq:latest - container_name: seq - restart: on-failure - ports: - - 8081:80 - - 5341:5341 - environment: - ACCEPT_EULA: Y - networks: - - ecommerce + # ####################################################### + # # seq + # ####################################################### + # seq: + # image: datalust/seq:latest + # container_name: seq + # restart: on-failure + # ports: + # - 8081:80 + # - 5341:5341 + # environment: + # ACCEPT_EULA: Y + # networks: + # - food-delivery # https://docs.docker.com/compose/networking/ # https://docs.docker.com/engine/reference/commandline/network_create/ # https://docs.docker.com/compose/compose-file/#networks-top-level-element # https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects # We could use also a predefined network and connect to that predefined network with specifying the 'name' of existing network and set 'external' attribute to true -# When we run docker-compose up, Docker Compose will check if the 'ecommerce' network already exists. If it does not exist, it will create the 'ecommerce' network. If it exists, it will use the existing 'ecommerce' network. problem is that if we do a docker-compose down this network will delete and other docker-compose that use same network will fail because network deleted so its better we use `external` keyword for using a predefined network +# When we run docker-compose up, Docker Compose will check if the 'food-delivery' network already exists. If it does not exist, it will create the 'food-delivery' network. If it exists, it will use the existing 'food-delivery' network. problem is that if we do a docker-compose down this network will delete and other docker-compose that use same network will fail because network deleted so its better we use `external` keyword for using a predefined network networks: - ecommerce: - name: ecommerce + food-delivery: + name: food-delivery driver: bridge volumes: diff --git a/deployments/docker-compose/docker-compose.override.yaml b/deployments/docker-compose/docker-compose.override.yaml new file mode 100644 index 00000000..7c77a0a2 --- /dev/null +++ b/deployments/docker-compose/docker-compose.override.yaml @@ -0,0 +1,122 @@ +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.yaml' configs here for development, actually we can name this file also to docker-compose.dev.yaml but because `override` suffix infere implicitly by docker-compose we use this name + +# To build and debug the app on dev machine --> docker-compose -f docker-compose.yaml build +# To start and debug the app on dev machine --> docker-compose -f docker-compose.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + image: gateway:dev + build: + context: ../../ + dockerfile: src/ApiGateway/dev.Dockerfile + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + container_name: gateway-dev + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Catalogs/dev.Dockerfile + container_name: catalogs-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Identity/dev.Dockerfile + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + container_name: identity-dev + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Customers/dev.Dockerfile + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + container_name: customers-dev + + rabbitmq: + ports: + - 15672:15672 + - 5672:5672 + + postgres: + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + + mongo: + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS} + ports: + - ${MONGO_PORT}:${MONGO_PORT} diff --git a/deployments/docker-compose/docker-compose.services-win.yaml b/deployments/docker-compose/docker-compose.services-win.yaml new file mode 100644 index 00000000..166e976c --- /dev/null +++ b/deployments/docker-compose/docker-compose.services-win.yaml @@ -0,0 +1,147 @@ +# # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/gateway-service:${TAG:-latest} + container_name: gateway-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${GATEWAY_HTTP_SVC_PORT:-3000}:80 + - ${GATEWAY_HTTPs_SVC_PORT:-3001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + volumes: + - ${USERPROFILE}\.aspnet\https:/https:ro + networks: + - food-delivery + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/catalogs-service:${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + container_name: catalogs-${TAG:-latest} + ports: + - ${CATALOGS_HTTP_SVC_PORT:-4000}:80 + - ${CATALOGS_HTTPS_SVC_PORT:-4001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.catalogs + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ${USERPROFILE}\.aspnet\https:/https:ro + networks: + - food-delivery + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/identity-service:${TAG:-latest} + container_name: identity-{TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${IDENTITY_HTTP_SVC_PORT:-7000}:80 + - ${IDENTITY_HTTPS_SVC_PORT:-7001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attributebute + env_file: + - ./.env + - ./.env.identity + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ${USERPROFILE}\.aspnet\https:/https:ro + networks: + - food-delivery + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/customers-service:${TAG:-latest} + container_name: customers-{TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${CUSTOMERS_HTTP_SVC_PORT:-8000}:80 + - ${CUSTOMERS_HTTPS_SVC_PORT:-8001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.customers + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ${USERPROFILE}\.aspnet\https:/https:ro + networks: + - food-delivery + +# https://docs.docker.com/compose/networking/ +# https://docs.docker.com/engine/reference/commandline/network_create/ +# https://docs.docker.com/compose/compose-file/#networks-top-level-element +# https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects +# We could use also a predefined network and connect to that predefined network with specifying the 'name' of existing network and set 'external' attribute to true +# When we run docker-compose up, Docker Compose will check if the 'food-delivery' network already exists. If it does not exist, it will create the 'food-delivery' network. If it exists, it will use the existing 'food-delivery' network. problem is that if we do a docker-compose down this network will delete and other docker-compose that use same network will fail because network deleted so its better we use `external` keyword for using a predefined network +networks: + food-delivery: + name: food-delivery + driver: bridge + # we can use the network that will create by infrastructure docker-compose file and we use that network here by specifying existing network 'name' and set 'external' attribute to 'true' (because we want to use a network outside of our docker-compose) or we can create a `food-delivery` network manually by `docker network create -d bridge food-delivery` and use this network as external network for all docker-compose files + external: true diff --git a/deployments/docker-compose/docker-compose.services.debug-win.yaml b/deployments/docker-compose/docker-compose.services.debug-win.yaml new file mode 100644 index 00000000..d4468b9b --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.debug-win.yaml @@ -0,0 +1,224 @@ +# https://www.richard-banks.org/2018/07/debugging-core-in-docker.html +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.yaml' configs here for debug mode + +# To build and debug the app on debug machine --> docker-compose -f docker-compose.yaml -f docker-compose.debug.yml build +# To start and debug the app on debug machine --> docker-compose -f docker-compose.yaml -f docker-compose.debug.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: gateway:dev + build: + target: final + context: ..\..\ + dockerfile: src\ApiGateway\dev.Dockerfile + container_name: gateway-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=docker + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\ApiGateway\FoodDelivery.ApiGateway\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\ApiGateway\dev.Dockerfile\FoodDelivery.ApiGateway\appsettings.docker.json:/app/appsettings.docker.json + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs:dev + build: + target: final + context: ..\..\ + dockerfile: src\Services\Catalogs\dev.Dockerfile + container_name: catalogs-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api\appsettings.docker.json + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity:dev + build: + target: final + context: ..\..\ + dockerfile: src\Services\Identity\dev.Dockerfile + container_name: identity-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Identity\FoodDelivery.Services.Identity.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Identity\FoodDelivery.Services.Identity.Api\appsettings.docker.json + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers:dev + build: + target: final + context: ..\..\ + dockerfile: src\Services\Customers\dev.Dockerfile + container_name: customers-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypointfile/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Customers\FoodDelivery.Services.Customers.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Customers\FoodDelivery.Services.Customers.Api\appsettings.docker.json diff --git a/deployments/docker-compose/docker-compose.services.debug.yaml b/deployments/docker-compose/docker-compose.services.debug.yaml new file mode 100644 index 00000000..4bfa6c5b --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.debug.yaml @@ -0,0 +1,224 @@ +# https://www.richard-banks.org/2018/07/debugging-core-in-docker.html +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.yaml' configs here for debug mode + +# To build and debug the app on debug machine --> docker-compose -f docker-compose.yaml -f docker-compose.debug.yml build +# To start and debug the app on debug machine --> docker-compose -f docker-compose.yaml -f docker-compose.debug.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: gateway:dev + build: + target: final + context: ../../ + dockerfile: src/ApiGateway/dev.Dockerfile + container_name: gateway-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=docker + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/ApiGateway/FoodDelivery.ApiGateway/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/ApiGateway/dev.Dockerfile/FoodDelivery.ApiGateway/appsettings.docker.json:/app/appsettings.docker.json + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs:dev + build: + target: final + context: ../../ + dockerfile: src/Services/Catalogs/dev.Dockerfile + container_name: catalogs-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity:dev + build: + target: final + context: ../../ + dockerfile: src/Services/Identity/dev.Dockerfile + container_name: identity-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Identity/FoodDelivery.Services.Identity.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers:dev + build: + target: final + context: ../../ + dockerfile: src/Services/Customers/dev.Dockerfile + container_name: customers-debug + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod we can use appsetting volume map for waching our setting by app for the volume + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + - ASPNETCORE_ENVIRONMENT=doc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + # https://phoenixnap.com/kb/docker-run-override-entrypoint + # https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime + # https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ + # https://github.com/microsoft/vscode-docker/issues/3831#issuecomment-1433567030 + # https://docs.docker.com/compose/compose-file/#entrypointfile/#entrypoint + ##https://stackoverflow.com/questions/38546755/docker-compose-keep-container-running + # for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage inner container because we want to run container app with debugger launcher + entrypoint: /bin/bash + tty: true # docker run -t + stdin_open: true # docker run -i + restart: "no" + # https://docs.docker.com/engine/reference/run/#foreground + # https://www.baeldung.com/ops/docker-compose-interactive-shell#interactive-shell-in-docker-docker-compose-yml-47a72891aee2 + # https://stackoverflow.com/questions/22272401/what-does-it-mean-to-attach-a-tty-std-in-out-to-dockers-or-lxc + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Customers/FoodDelivery.Services.Customers.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json diff --git a/deployments/docker-compose/docker-compose.services.dev-win.yaml b/deployments/docker-compose/docker-compose.services.dev-win.yaml new file mode 100644 index 00000000..b1c2a011 --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.dev-win.yaml @@ -0,0 +1,167 @@ +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.services.yaml' configs here for development mode + +# To build and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.dev.yml build +# To start and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.dev.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + image: gateway:dev + build: + context: ..\..\ + dockerfile: src\ApiGateway\dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: gateway-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\ApiGateway\FoodDelivery.ApiGateway\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\ApiGateway\dev.Dockerfile\FoodDelivery.ApiGateway\appsettings.docker.json:/app/appsettings.docker.json + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ..\..\ + dockerfile: src\Services\Catalogs\dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: catalogs-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api\appsettings.docker.json + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ..\..\ + dockerfile: src\Services\Identity\dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: identity-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Identity\FoodDelivery.Services.Identity.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Identity/FoodDelivery.Services.Identity.Api\appsettings.docker.json + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ..\..\ + dockerfile: src\Services\Customers\dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: customers-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ${USERPROFILE}\vsdbg:/vsdbg:ro + - ${USERPROFILE}\.nuget\packages:/root/.nuget/packages:ro + - ${USERPROFILE}\.nuget\packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src\Services\Customers\FoodDelivery.Services.Customers.Api\${ConfigurationFolder:-config-files\}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - .\src\Services\Customers\FoodDelivery.Services.Customers.Api\appsettings.docker.json diff --git a/deployments/docker-compose/docker-compose.services.dev.yaml b/deployments/docker-compose/docker-compose.services.dev.yaml new file mode 100644 index 00000000..c4b73074 --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.dev.yaml @@ -0,0 +1,167 @@ +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.services.yaml' configs here for development mode + +# To build and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.dev.yml build +# To start and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.dev.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + image: gateway:dev + build: + context: ../../ + dockerfile: src/ApiGateway/dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: gateway-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/ApiGateway/FoodDelivery.ApiGateway/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/ApiGateway/dev.Dockerfile/FoodDelivery.ApiGateway/appsettings.docker.json:/app/appsettings.docker.json + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Catalogs/dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: catalogs-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Identity/dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: identity-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Identity/FoodDelivery.Services.Identity.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers:dev + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Customers/dev.Dockerfile + # Environment Variables are only passed to processes upon startup, Environment Variables are not reloaded once the process has started so if we need to realod app we can use volume map for waching our new setting from `config-files` folder in the the volume or volume map for appsettings.json file + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + environment: + # https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration + # https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables + # https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes + # Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds + # If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConfigurationFolder=${ConfigurationFolder:-config-files/} + container_name: customers-dev + # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html + # https://copyprogramming.com/howto/docker-compose-says-pwd-variable-not-set-windows + # https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes#installing-vsdbg-on-the-server + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # https://github.com/docker/compose/issues/5089#issuecomment-321822300 + # this mappings increase the size of docker image so we use it just in dev, debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size + # here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `identity-debug` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` + # https://docs.docker.com/storage/volumes/#use-a-read-only-volume + volumes: + - ~/vsdbg:/vsdbg:ro + - ~/.nuget/packages:/root/.nuget/packages:ro + - ~/.nuget/packages:/home/appuser/.nuget/packages:ro + # https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + # if we need to reload app, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in volume map + - src/Services/Customers/FoodDelivery.Services.Customers.Api/${ConfigurationFolder:-config-files/}:/app/config-files/ + # https://levelup.gitconnected.com/docker-environment-variables-appsettings-json-net-bdac052bf3db + # https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + - ./src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json diff --git a/deployments/docker-compose/docker-compose.services.prod.yaml b/deployments/docker-compose/docker-compose.services.prod.yaml new file mode 100644 index 00000000..a788bb28 --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.prod.yaml @@ -0,0 +1,62 @@ +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ +# Overrid 'docker-compose.services.yaml' configs here for production mode + +# To build and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.prod.yml build +# To start and debug the app on dev machine --> docker-compose -f docker-compose.services.yaml -f docker-compose.services.prod.yaml up -d + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + image: gateway-service:${TAG:-latest} + build: + context: ../../ + dockerfile: src/ApiGateway/Dockerfile + container_name: gateway-prod + restart: "no" + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: catalogs-service:${TAG:-latest} + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Catalogs/Dockerfile + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: "no" + container_name: catalogs-prod + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: identity-service:${TAG:-latest} + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Identity/Dockerfile + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: "no" + container_name: identity-prod + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + image: customers-service:${TAG:-latest} + build: + # the .dockerignore needs to be in the root of your build context. The build context is the directory you pass at the end of the build command, often a . or the current directory + # we can use a .dockerignore file to exclude some files or directories from build context + # https://docs.docker.com/build/building/context/ + # https://docs.docker.com/engine/reference/commandline/build/ + # https://www.howtogeek.com/devops/understanding-the-docker-build-context-why-you-should-use-dockerignore/ + context: ../../ + dockerfile: src/Services/Customers/Dockerfile + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: "no" + container_name: customers-prod diff --git a/deployments/docker-compose/docker-compose.services.yaml b/deployments/docker-compose/docker-compose.services.yaml new file mode 100644 index 00000000..71ccf95a --- /dev/null +++ b/deployments/docker-compose/docker-compose.services.yaml @@ -0,0 +1,147 @@ +# # https://www.richard-banks.org/2018/07/debugging-core-in-docker.html +# https://docs.docker.com/compose/reference/#use--f-to-specify-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/extends/ + +version: "3.8" +services: + gateway: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-servicemage + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/gateway-service:${TAG:-latest} + container_name: gateway-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${GATEWAY_HTTP_SVC_PORT:-3000}:80 + - ${GATEWAY_HTTPs_SVC_PORT:-3001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + volumes: + - ~/.aspnet/https:/https:ro + networks: + - food-delivery + + catalogs: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/catalogs-service:${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + container_name: catalogs-${TAG:-latest} + ports: + - ${CATALOGS_HTTP_SVC_PORT:-4000}:80 + - ${CATALOGS_HTTPS_SVC_PORT:-4001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.catalogs + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + networks: + - food-delivery + + identity: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/identity-service:${TAG:-latest} + container_name: identity-{TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${IDENTITY_HTTP_SVC_PORT:-7000}:80 + - ${IDENTITY_HTTPS_SVC_PORT:-7001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attributebute + env_file: + - ./.env + - ./.env.identity + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + networks: + - food-delivery + + customers: + # https://nickjanetakis.com/blog/docker-tip-57-using-build-and-image-in-the-same-docker-compose-service + # https://github.com/opencontainers/.github/blob/master/docs/docs/introduction/digests.md + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with-an-env-file + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-from-the-shell + # Substitute for replacing evnironment only support from `.env file substitute` and `shell substitute` for reading shell based environemnts + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/customers-service:${TAG:-latest} + container_name: customers-{TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${CUSTOMERS_HTTP_SVC_PORT:-8000}:80 + - ${CUSTOMERS_HTTPS_SVC_PORT:-8001}:443 + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.customers + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + networks: + - food-delivery + +# https://docs.docker.com/compose/networking/ +# https://docs.docker.com/engine/reference/commandline/network_create/ +# https://docs.docker.com/compose/compose-file/#networks-top-level-element +# https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects +# We could use also a predefined network and connect to that predefined network with specifying the 'name' of existing network and set 'external' attribute to true +# When we run docker-compose up, Docker Compose will check if the 'food-delivery' network already exists. If it does not exist, it will create the 'food-delivery' network. If it exists, it will use the existing 'food-delivery' network. problem is that if we do a docker-compose down this network will delete and other docker-compose that use same network will fail because network deleted so its better we use `external` keyword for using a predefined network +networks: + food-delivery: + name: food-delivery + driver: bridge + # we can use the network that will create by infrastructure docker-compose file and we use that network here by specifying existing network 'name' and set 'external' attribute to 'true' (because we want to use a network outside of our docker-compose) or we can create a `food-delivery` network manually by `docker network create -d bridge food-delivery` and use this network as external network for all docker-compose files + external: true diff --git a/deployments/docker-compose/docker-compose.yaml b/deployments/docker-compose/docker-compose.yaml new file mode 100644 index 00000000..1279573f --- /dev/null +++ b/deployments/docker-compose/docker-compose.yaml @@ -0,0 +1,390 @@ +# https://docs.docker.com/compose/environment-variables/#the-env-file +# https://github.com/NuGet/Home/issues/10491#issuecomment-778841003 +version: "3.8" + +services: + gateway: + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/gateway-service:${TAG:-latest} + container_name: gateway-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${GATEWAY_HTTP_SVC_PORT:-3000}:80 + - ${GATEWAY_HTTPS_SVC_PORT:-3001}:443 + networks: + - food-delivery + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + volumes: + - ~/.aspnet/https:/https:ro + + catalogs: + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/catalogs-service:${TAG:-latest} + container_name: catalogs-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${CATALOGS_HTTP_SVC_PORT:-4000}:80 + - ${CATALOGS_HTTPS_SVC_PORT:-4001}:443 + depends_on: + - postgres + - rabbitmq + - mongo + networks: + - food-delivery + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.catalogs + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + + identity: + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/identity-service:${TAG:-latest} + container_name: identity-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${IDENTITY_HTTP_SVC_PORT:-7000}:80 + - ${IDENTITY_HTTPS_SVC_PORT:-7001}:443 + depends_on: + - postgres + - rabbitmq + networks: + - food-delivery + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.identity + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + + customers: + image: ${REGISTRY:-ghcr.io}/${PROJECT_NAME:-mehdihadeli/food-delivery-microservices}/customers-service:${TAG:-latest} + container_name: customers-${TAG:-latest} + # https://docs.docker.com/compose/compose-file/compose-file-v3/#restart + restart: on-failure + ports: + - ${CUSTOMERS_HTTP_SVC_PORT:-8000}:80 + - ${CUSTOMERS_HTTPS_SVC_PORT:-8001}:443 + depends_on: + - postgres + - rabbitmq + - mongo + networks: + - food-delivery + # https://docs.docker.com/compose/environment-variables/set-environment-variables/ + # https://docs.docker.com/compose/environment-variables/env-file/ + # https://docs.docker.com/compose/environment-variables/set-environment-variables/#substitute-with---env-file + # Substitute env files and replacing (without passing to container) inner docker-compose files with `--env-file` and pass the file as an argument in the CLI, If the --env-file is not used in the command line, the .env file is loaded by default(docker compose --env-file ./config/.env.dev up) + # Pass multiple environment variables from an external file through to a service’s `containers` with the `env_file` attribute + env_file: + - ./.env + - ./.env.customers + # https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-7.0#windows-using-windows-containers + environment: + - ASPNETCORE_ENVIRONMENT=docker + - ASPNETCORE_URLS=https://+:443;http://+:80 + - ASPNETCORE_Kestrel__Certificates__Default__Password=${DEV_CERT_PASSWORD:-your_password} + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + volumes: + - ~/.aspnet/https:/https:ro + + ####################################################### + # rabbitmq + ####################################################### + rabbitmq: + image: rabbitmq:management + container_name: rabbitmq + restart: on-failure + ports: + - 5672:5672 + - 15672:15672 + # volumes: + # - rabbitmq:/var/lib/rabbitmq + networks: + - food-delivery + + ####################################################### + # mongo + ####################################################### + mongo: + image: mongo + container_name: mongo + restart: on-failure + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS} + ports: + - ${MONGO_PORT}:${MONGO_PORT} + networks: + - food-delivery + + ####################################################### + # postgres + ####################################################### + postgres: + image: postgres:latest + container_name: postgres + restart: on-failure + ports: + - "5432:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + networks: + - food-delivery + + ####################################################### + # eventstore-db + ####################################################### + eventstore: + image: eventstore/eventstore:latest + container_name: eventstore + restart: on-failure + environment: + - EVENTSTORE_CLUSTER_SIZE=1 + - EVENTSTORE_RUN_PROJECTIONS=All + - EVENTSTORE_START_STANDARD_PROJECTIONS=false + - EVENTSTORE_EXT_TCP_PORT=1113 + - EVENTSTORE_HTTP_PORT=2113 + - EVENTSTORE_INSECURE=true + - EVENTSTORE_ENABLE_EXTERNAL_TCP=true + - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true + - EVENTSTORE_MEM_DB=true + ports: + - "1113:1113" + - "2113:2113" + volumes: + - type: volume + source: eventstore-volume-data + target: /var/lib/eventstore + - type: volume + source: eventstore-volume-logs + target: /var/log/eventstore + networks: + - food-delivery + + # ####################################################### + # # Redis + # ####################################################### + + # redis: + # image: redis + # container_name: redis + # restart: unless-stopped + # networks: + # - food-delivery + # ports: + # - 6379:6379 + # volumes: + # - redis:/data + + ####################################################### + # Portainer + ####################################################### + # https://bobcares.com/blog/install-portainer-docker-compose/ + portainer: + image: portainer/portainer-ce:latest + container_name: portainer + restart: unless-stopped + security_opt: + - no-new-privileges:true + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./portainer-data:/data + ports: + - 9000:9000 + networks: + - food-delivery + + ####################################################### + # elasticsearch + ####################################################### + elasticsearch: + container_name: elastic_search + restart: on-failure + image: docker.elastic.co/elasticsearch/elasticsearch:latest + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - xpack.monitoring.enabled=true + - xpack.watcher.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - elastic-data:/usr/share/elasticsearch/data + ports: + - "9200:9200" + - "9300:9300" + networks: + - food-delivery + + ####################################################### + # kibana + ####################################################### + kibana: + image: docker.elastic.co/kibana/kibana:latest + container_name: kibana + restart: on-failure + environment: + - ELASTICSEARCH_HOSTS=http://elastic_search:9200 + ports: + - "5601:5601" + networks: + - food-delivery + depends_on: + - elasticsearch + + jaeger: + container_name: jaeger + restart: on-failure + image: jaegertracing/all-in-one:latest + ports: + - "16686:16686" + - "14268:14268" + - "14250:14250" + networks: + - food-delivery + + ####################################################### + # zipkin + ####################################################### + zipkin: + image: openzipkin/zipkin:latest + restart: on-failure + container_name: zipkin + ports: + - "9411:9411" + networks: + - food-delivery + + ####################################################### + # otel-collector + ####################################################### + otel-collector: + image: otel/opentelemetry-collector-contrib-dev:latest + command: ["--config=/etc/otel-collector-config.yaml", ""] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "1888:1888" # pprof extension + - "8888:8888" # Prometheus metrics exposed by the collector + - "8889:8889" # Prometheus exporter metrics + - "13133:13133" # health_check extension + - "4317:4317" # OTLP gRPC receiver + - "55679:55679" # zpages extension + depends_on: + - jaeger + - zipkin + networks: + - food-delivery + + ####################################################### + # prometheus + ####################################################### + prometheus: + image: prom/prometheus:latest + container_name: prometheus + restart: on-failure + user: root + ports: + - "9090:9090" + command: + - --config.file=/etc/prometheus/prometheus.yml + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - food-delivery + + ####################################################### + # node_exporter + ####################################################### + node_exporter: + container_name: node_exporter + restart: on-failure + image: prom/node-exporter + ports: + - "9101:9100" + networks: + - food-delivery + + ####################################################### + # grafana + ####################################################### + grafana: + container_name: grafana + restart: on-failure + image: grafana/grafana + ports: + - "3000:3000" + networks: + - food-delivery + + ####################################################### + # seq + ####################################################### + seq: + image: datalust/seq:latest + container_name: seq + restart: on-failure + ports: + - 8081:80 + - 5341:5341 + environment: + ACCEPT_EULA: Y + networks: + - food-delivery + +# https://docs.docker.com/compose/networking/ +# https://docs.docker.com/engine/reference/commandline/network_create/ +# https://docs.docker.com/compose/compose-file/#networks-top-level-element +# https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects +# We could use also a predefined network and connect to that predefined network with specifying the 'name' of existing network and set 'external' attribute to true +# When we run docker-compose up, Docker Compose will check if the 'food-delivery' network already exists. If it does not exist, it will create the 'food-delivery' network. If it exists, it will use the existing 'food-delivery' network. problem is that if we do a docker-compose down this network will delete and other docker-compose that use same network will fail because network deleted so its better we use `external` keyword for using a predefined network +networks: + food-delivery: + name: food-delivery + driver: bridge + +volumes: + eventstore-volume-data: + eventstore-volume-logs: + elastic-data: diff --git a/deployments/docker-compose/docker.txt b/deployments/docker-compose/docker.txt index e2d1db46..645cc529 100644 --- a/deployments/docker-compose/docker.txt +++ b/deployments/docker-compose/docker.txt @@ -1,2 +1,2 @@ -docker network create -d bridge ecommerce +docker network create -d bridge food-delivery diff --git a/deployments/docker-compose/monitoring/prometheus.yml b/deployments/docker-compose/monitoring/prometheus.yml new file mode 100644 index 00000000..87f145f0 --- /dev/null +++ b/deployments/docker-compose/monitoring/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + - job_name: "todo-api" + static_configs: + - targets: ["host.docker.internal:5000"] \ No newline at end of file diff --git a/deployments/tye/tye.yaml b/deployments/tye/tye.yaml new file mode 100644 index 00000000..7bedcf3c --- /dev/null +++ b/deployments/tye/tye.yaml @@ -0,0 +1,46 @@ +# https://github.com/dotnet/tye/tree/main/docs +# https://github.com/dotnet/tye/blob/main/docs/reference/schema.md#environment-variables +# https://www.daveabrock.com/2020/08/19/microservices-with-tye-1/ +# https://www.daveabrock.com/2020/08/27/microservices-with-tye-2/ +# https://github.com/dotnet/tye/tree/main/samples +# https://devblogs.microsoft.com/dotnet/introducing-project-tye/ + +name: tye-ecomerce +containerEngine: docker +registry: mehdihadeli + +services: + - name: gateway + project: ./../../src/ApiGateway/FoodDelivery.ApiGateway/ApiGateway.csproj + env_file: + - .env + bindings: + - port: 3000 + + - name: catalog-service + project: ./../../src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj + env_file: + - .env + bindings: + - port: 4000 + + - name: customers-service + project: ./../../src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj + env_file: + - .env + bindings: + - port: 8000 + + - name: identity-service + project: ./../../src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj + env_file: + - .env + bindings: + - port: 7000 + + - name: order-service + project: ./../../src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj + env_file: + - .env + bindings: + - port: 9000 diff --git a/food-delivery-microservices.sln b/food-delivery-microservices.sln new file mode 100644 index 00000000..2e43f664 --- /dev/null +++ b/food-delivery-microservices.sln @@ -0,0 +1,530 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AC15A422-22C5-46C7-A9F4-3473AB68E8BE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ApiGateway", "ApiGateway", "{5054DD25-B14D-4A6D-A349-951C323BD564}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.ApiGateway", "src\ApiGateway\FoodDelivery.ApiGateway\FoodDelivery.ApiGateway.csproj", "{CD058825-516B-45F8-8C98-640C58D6F0FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildingBlocks", "BuildingBlocks", "{AC8D1CE6-BAF8-4A05-8F87-232598A756C3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Abstractions", "src\BuildingBlocks\BuildingBlocks.Abstractions\BuildingBlocks.Abstractions.csproj", "{59481001-CCA3-468E-BBD4-952B3901F4CC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Caching", "src\BuildingBlocks\BuildingBlocks.Caching\BuildingBlocks.Caching.csproj", "{5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Core", "src\BuildingBlocks\BuildingBlocks.Core\BuildingBlocks.Core.csproj", "{AF9C1990-2581-449B-BC07-95E07ABD8C5B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Email", "src\BuildingBlocks\BuildingBlocks.Email\BuildingBlocks.Email.csproj", "{6DA9C00A-D2B7-4E30-9543-CD4C088A2092}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.HealthCheck", "src\BuildingBlocks\BuildingBlocks.HealthCheck\BuildingBlocks.HealthCheck.csproj", "{D02D6036-1987-4905-A996-3B030FDABDDE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Integration.MassTransit", "src\BuildingBlocks\BuildingBlocks.Integration.MassTransit\BuildingBlocks.Integration.MassTransit.csproj", "{CB0F3968-47D0-4C65-8962-29CF9703E066}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Logging", "src\BuildingBlocks\BuildingBlocks.Logging\BuildingBlocks.Logging.csproj", "{0FF6765F-54E9-431F-8AE9-D6130ED45E34}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Messaging.Persistence.Postgres", "src\BuildingBlocks\BuildingBlocks.Messaging.Persistence.Postgres\BuildingBlocks.Messaging.Persistence.Postgres.csproj", "{A0743EF6-D719-4D0C-8506-ED0E68AF8AAE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.OpenTelemetry", "src\BuildingBlocks\BuildingBlocks.OpenTelemetry\BuildingBlocks.OpenTelemetry.csproj", "{604F8AB9-AEA8-4B53-8358-94610E6C70E9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Persistence.EfCore.Postgres", "src\BuildingBlocks\BuildingBlocks.Persistence.EfCore.Postgres\BuildingBlocks.Persistence.EfCore.Postgres.csproj", "{E6BF0450-C60D-4C68-B204-91E6AF64288B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Persistence.EventStoreDB", "src\BuildingBlocks\BuildingBlocks.Persistence.EventStoreDB\BuildingBlocks.Persistence.EventStoreDB.csproj", "{CA8E46FF-9F59-46AE-BC46-A36047FCF64A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Persistence.Marten", "src\BuildingBlocks\BuildingBlocks.Persistence.Marten\BuildingBlocks.Persistence.Marten.csproj", "{17A943D1-9083-437A-8E45-449F54F2B67A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Persistence.Mongo", "src\BuildingBlocks\BuildingBlocks.Persistence.Mongo\BuildingBlocks.Persistence.Mongo.csproj", "{52F14439-F1F6-4036-B3A5-6D813F66C0D4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Resiliency", "src\BuildingBlocks\BuildingBlocks.Resiliency\BuildingBlocks.Resiliency.csproj", "{64B3BFDB-695D-462E-9879-9B0988802703}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Security", "src\BuildingBlocks\BuildingBlocks.Security\BuildingBlocks.Security.csproj", "{D2C1FD2B-68A6-4585-807E-27516D43019A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Swagger", "src\BuildingBlocks\BuildingBlocks.Swagger\BuildingBlocks.Swagger.csproj", "{313951FD-307C-4CE4-ADC5-09B1EFFFFF43}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Validation", "src\BuildingBlocks\BuildingBlocks.Validation\BuildingBlocks.Validation.csproj", "{AFC9C0A9-C670-4881-AD76-72348D661A53}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Web", "src\BuildingBlocks\BuildingBlocks.Web\BuildingBlocks.Web.csproj", "{D5503E7F-10E7-4BA1-9611-86E92FC19ACD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7528202E-DFFE-4295-BAED-6F40D8D62864}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{F397611D-54BB-4ED3-BB4F-C34BE9308B40}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests.Shared", "tests\Shared\Tests.Shared\Tests.Shared.csproj", "{0794E6DA-609E-46A8-8C98-B7EC56152BD4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{BCCDF699-8F57-4E28-BEF8-014770B52DD2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Billing", "Billing", "{6025FFB1-0237-4657-A450-1479829D5892}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Billing", "src\Services\Billing\FoodDelivery.Services.Billing\FoodDelivery.Services.Billing.csproj", "{FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Billing.Api", "src\Services\Billing\FoodDelivery.Services.Billing.Api\FoodDelivery.Services.Billing.Api.csproj", "{E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Carts", "Carts", "{603E0CDC-19F8-4201-8B7A-2D048FFE0415}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Carts", "src\Services\Carts\FoodDelivery.Services.Carts\FoodDelivery.Services.Carts.csproj", "{CE84DF60-05F9-4FC5-9604-DD52568ECAA9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Carts.Api", "src\Services\Carts\FoodDelivery.Services.Carts.Api\FoodDelivery.Services.Carts.Api.csproj", "{DEE17491-8C3E-44EA-A6FF-90C5132A3946}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalogs", "Catalogs", "{9454DA13-BA5A-40CE-AB43-67D15CD6D7A8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Catalogs", "src\Services\Catalogs\FoodDelivery.Services.Catalogs\FoodDelivery.Services.Catalogs.csproj", "{415B25B5-436E-45EC-A3E3-B660E172CC87}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Catalogs.Api", "src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api\FoodDelivery.Services.Catalogs.Api.csproj", "{C4D3CDF4-700E-49EB-B729-1C22291C67BE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Checkouts", "Checkouts", "{4B022AC7-B135-4D3F-92C3-F877DC8E9240}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Checkouts", "src\Services\Checkouts\FoodDelivery.Services.Checkouts\FoodDelivery.Services.Checkouts.csproj", "{4A1EE116-C6A2-4FDE-B428-F0F0583EBF57}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Checkouts.Api", "src\Services\Checkouts\FoodDelivery.Services.Checkouts.Api\FoodDelivery.Services.Checkouts.Api.csproj", "{2359695D-AD50-4522-81C9-FD11ADBBCB47}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Customers", "Customers", "{45BB845E-7B34-43A8-8251-93B9238E1B21}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Customers", "src\Services\Customers\FoodDelivery.Services.Customers\FoodDelivery.Services.Customers.csproj", "{A7E8E59A-7BAB-4846-9C96-96A2BDB966AD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Customers.Api", "src\Services\Customers\FoodDelivery.Services.Customers.Api\FoodDelivery.Services.Customers.Api.csproj", "{FA448F88-C24C-4AAB-A12A-49B2F5280993}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GroceryStores", "GroceryStores", "{98CC543B-2C97-4ECC-802D-D09FFD051CB8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.GroceryStores", "src\Services\GroceryStores\FoodDelivery.Services.GroceryStores\FoodDelivery.Services.GroceryStores.csproj", "{615D089B-EDB7-4978-9073-44D8A28D356E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.GroceryStores.Api", "src\Services\GroceryStores\FoodDelivery.Services.GroceryStores.Api\FoodDelivery.Services.GroceryStores.Api.csproj", "{9A90751B-C662-4D88-B789-87FCE7254B08}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{1FB16AE8-2E95-40A0-B402-CEF47CAE84ED}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Identity", "src\Services\Identity\FoodDelivery.Services.Identity\FoodDelivery.Services.Identity.csproj", "{998CA6F9-C932-4DE9-85D2-8A695D213014}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Identity.Api", "src\Services\Identity\FoodDelivery.Services.Identity.Api\FoodDelivery.Services.Identity.Api.csproj", "{4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Orders", "Orders", "{3DA1CB7D-1B79-411F-B077-18CB8B9D93FD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Orders", "src\Services\Orders\FoodDelivery.Services.Orders\FoodDelivery.Services.Orders.csproj", "{D4AA9009-BE4B-4B10-AA0E-9019C7489117}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Orders.Api", "src\Services\Orders\FoodDelivery.Services.Orders.Api\FoodDelivery.Services.Orders.Api.csproj", "{C81EBA83-0ACB-4274-9026-E2B750F9AF9B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pricing", "Pricing", "{226F5499-F077-4B20-B7EB-AF2830B26A05}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Pricing", "src\Services\Pricing\FoodDelivery.Services.Pricing\FoodDelivery.Services.Pricing.csproj", "{C961A0A1-CFCD-4862-99B5-A8F41B52A421}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Pricing.Api", "src\Services\Pricing\FoodDelivery.Services.Pricing.Api\FoodDelivery.Services.Pricing.Api.csproj", "{FDD154FB-5F44-4070-9663-7A650545765E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Recommendations", "Recommendations", "{2F1A88DC-D97F-4E61-8BC4-5F89A1034CFA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Recommendations", "src\Services\Recommendations\FoodDelivery.Services.Recommendations\FoodDelivery.Services.Recommendations.csproj", "{B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Recommendations.Api", "src\Services\Recommendations\FoodDelivery.Services.Recommendations.Api\FoodDelivery.Services.Recommendations.Api.csproj", "{17894AD0-C26D-4B76-87C5-773A7636B962}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Restaurants", "Restaurants", "{2454BC26-666F-4B65-80C5-EE209F15E1CF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Restaurants", "src\Services\Restaurants\FoodDelivery.Services.Restaurants\FoodDelivery.Services.Restaurants.csproj", "{FA96F2DA-B4E8-410B-83CF-EE16DF425C02}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Restaurants.Api", "src\Services\Restaurants\FoodDelivery.Services.Restaurants.Api\FoodDelivery.Services.Restaurants.Api.csproj", "{E8C72788-C72F-423D-83E2-8D101153D4A5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reviews", "Reviews", "{BD39BCFF-EA9E-4831-8F40-4CAFBA18119E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Reviews", "src\Services\Reviews\FoodDelivery.Services.Reviews\FoodDelivery.Services.Reviews.csproj", "{E0F26476-006F-429B-93AF-4EAE724EF1F4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Reviews.Api", "src\Services\Reviews\FoodDelivery.Services.Reviews.Api\FoodDelivery.Services.Reviews.Api.csproj", "{0ED52629-B128-4B2B-B3F4-FBA5B6432C93}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Search", "Search", "{45C8D293-893F-4F15-9F51-5242EC89D69B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Search", "src\Services\Search\FoodDelivery.Services.Search\FoodDelivery.Services.Search.csproj", "{56BD5D23-B7D0-430C-ADBE-E91832699C05}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Search.Api", "src\Services\Search\FoodDelivery.Services.Search.Api\FoodDelivery.Services.Search.Api.csproj", "{3FE31761-5C82-4487-9839-ED013BEBC184}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{1634E768-03DD-4845-BACD-746A1CABC235}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Shared", "src\Services\Shared\FoodDelivery.Services.Shared\FoodDelivery.Services.Shared.csproj", "{DAD0BB78-83EB-4A71-8A18-113635AB5B90}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shippings", "Shippings", "{7C2EF356-A977-4634-A7CC-FF4305DF9528}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FoodDelivery.Services.Shippings.Api", "src\Services\Shippings\FoodDelivery.Services.Shippings.Api\FoodDelivery.Services.Shippings.Api.csproj", "{BB2BCA75-3CCF-4499-8C12-0C9E3183045D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildingBlocks", "BuildingBlocks", "{AD26F940-D9E4-40A6-8625-5AE210511482}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MassTransit", "MassTransit", "{CFDD8FEB-EB9C-4F5C-AAEE-002B8E4AEB87}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildingBlocks.Integration.MassTransit.IntegrationTests", "tests\BuildingBlocks\MassTransit\BuildingBlocks.Integration.MassTransit.IntegrationTests\BuildingBlocks.Integration.MassTransit.IntegrationTests.csproj", "{A34518E3-A4E8-4BBB-9FCA-E875F2030BA6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{738344E5-5CBE-4C37-AE52-90084CEACFC4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Customers", "Customers", "{0512E4C6-76E9-4DA4-8881-D1D6695E48F5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Orders", "Orders", "{81014D1C-5EEB-4583-969B-D5A38A591235}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{A9B8E9D0-8E3C-4495-B8FE-CBBAE3D46E62}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalogs", "Catalogs", "{FD68316B-FCDA-4241-8A32-2B2C0863CE7D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.UnitTests", "tests\Services\Customers\FoodDelivery.Services.Customers.UnitTests\FoodDelivery.Services.Customers.UnitTests.csproj", "{8B636688-5CB1-47E6-AC8B-B6731E3E3F0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.TestShared", "tests\Services\Customers\FoodDelivery.Services.Customers.TestShared\FoodDelivery.Services.Customers.TestShared.csproj", "{EBACC7BC-9151-45B0-88DE-C118298FBDCB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.LoadTests", "tests\Services\Customers\FoodDelivery.Services.Customers.LoadTests\FoodDelivery.Services.Customers.LoadTests.csproj", "{C103E27F-1D27-4200-9647-4396CF0916FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.IntegrationTests", "tests\Services\Customers\FoodDelivery.Services.Customers.IntegrationTests\FoodDelivery.Services.Customers.IntegrationTests.csproj", "{F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.EndToEndTests", "tests\Services\Customers\FoodDelivery.Services.Customers.EndToEndTests\FoodDelivery.Services.Customers.EndToEndTests.csproj", "{203670E5-177B-46DC-B1E7-E529147A2FB1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Customers.DependencyTests", "tests\Services\Customers\FoodDelivery.Services.Customers.DependencyTests\FoodDelivery.Services.Customers.DependencyTests.csproj", "{DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.UnitTests", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.UnitTests\FoodDelivery.Services.Catalogs.UnitTests.csproj", "{4823D587-E11A-49D8-A798-5D34129EAC8A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.TestShared", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.TestShared\FoodDelivery.Services.Catalogs.TestShared.csproj", "{9062925D-497D-4B4C-885A-BF88001A1E5A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.LoadTests", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.LoadTests\FoodDelivery.Services.Catalogs.LoadTests.csproj", "{8CD782B3-2075-4F65-860A-CDF962B2B51C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.IntegrationTests", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.IntegrationTests\FoodDelivery.Services.Catalogs.IntegrationTests.csproj", "{08882BEC-26B6-4994-A6DD-A6066AA07CAF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.EndToEndTests", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.EndToEndTests\FoodDelivery.Services.Catalogs.EndToEndTests.csproj", "{5284251C-2245-4BE9-9A7A-E014D2704763}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery.Services.Catalogs.DependencyTests", "tests\Services\Catalogs\FoodDelivery.Services.Catalogs.DependencyTests\FoodDelivery.Services.Catalogs.DependencyTests.csproj", "{38C7E337-91B3-4075-B3B6-7BCC9D5B87FA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CD058825-516B-45F8-8C98-640C58D6F0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD058825-516B-45F8-8C98-640C58D6F0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD058825-516B-45F8-8C98-640C58D6F0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD058825-516B-45F8-8C98-640C58D6F0FC}.Release|Any CPU.Build.0 = Release|Any CPU + {59481001-CCA3-468E-BBD4-952B3901F4CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59481001-CCA3-468E-BBD4-952B3901F4CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59481001-CCA3-468E-BBD4-952B3901F4CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59481001-CCA3-468E-BBD4-952B3901F4CC}.Release|Any CPU.Build.0 = Release|Any CPU + {5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2}.Release|Any CPU.Build.0 = Release|Any CPU + {AF9C1990-2581-449B-BC07-95E07ABD8C5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF9C1990-2581-449B-BC07-95E07ABD8C5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF9C1990-2581-449B-BC07-95E07ABD8C5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF9C1990-2581-449B-BC07-95E07ABD8C5B}.Release|Any CPU.Build.0 = Release|Any CPU + {6DA9C00A-D2B7-4E30-9543-CD4C088A2092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DA9C00A-D2B7-4E30-9543-CD4C088A2092}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DA9C00A-D2B7-4E30-9543-CD4C088A2092}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DA9C00A-D2B7-4E30-9543-CD4C088A2092}.Release|Any CPU.Build.0 = Release|Any CPU + {D02D6036-1987-4905-A996-3B030FDABDDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D02D6036-1987-4905-A996-3B030FDABDDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D02D6036-1987-4905-A996-3B030FDABDDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D02D6036-1987-4905-A996-3B030FDABDDE}.Release|Any CPU.Build.0 = Release|Any CPU + {CB0F3968-47D0-4C65-8962-29CF9703E066}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB0F3968-47D0-4C65-8962-29CF9703E066}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB0F3968-47D0-4C65-8962-29CF9703E066}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB0F3968-47D0-4C65-8962-29CF9703E066}.Release|Any CPU.Build.0 = Release|Any CPU + {0FF6765F-54E9-431F-8AE9-D6130ED45E34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0FF6765F-54E9-431F-8AE9-D6130ED45E34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0FF6765F-54E9-431F-8AE9-D6130ED45E34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FF6765F-54E9-431F-8AE9-D6130ED45E34}.Release|Any CPU.Build.0 = Release|Any CPU + {A0743EF6-D719-4D0C-8506-ED0E68AF8AAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0743EF6-D719-4D0C-8506-ED0E68AF8AAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0743EF6-D719-4D0C-8506-ED0E68AF8AAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0743EF6-D719-4D0C-8506-ED0E68AF8AAE}.Release|Any CPU.Build.0 = Release|Any CPU + {604F8AB9-AEA8-4B53-8358-94610E6C70E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {604F8AB9-AEA8-4B53-8358-94610E6C70E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {604F8AB9-AEA8-4B53-8358-94610E6C70E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {604F8AB9-AEA8-4B53-8358-94610E6C70E9}.Release|Any CPU.Build.0 = Release|Any CPU + {E6BF0450-C60D-4C68-B204-91E6AF64288B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6BF0450-C60D-4C68-B204-91E6AF64288B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6BF0450-C60D-4C68-B204-91E6AF64288B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6BF0450-C60D-4C68-B204-91E6AF64288B}.Release|Any CPU.Build.0 = Release|Any CPU + {CA8E46FF-9F59-46AE-BC46-A36047FCF64A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA8E46FF-9F59-46AE-BC46-A36047FCF64A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA8E46FF-9F59-46AE-BC46-A36047FCF64A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA8E46FF-9F59-46AE-BC46-A36047FCF64A}.Release|Any CPU.Build.0 = Release|Any CPU + {17A943D1-9083-437A-8E45-449F54F2B67A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17A943D1-9083-437A-8E45-449F54F2B67A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17A943D1-9083-437A-8E45-449F54F2B67A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17A943D1-9083-437A-8E45-449F54F2B67A}.Release|Any CPU.Build.0 = Release|Any CPU + {52F14439-F1F6-4036-B3A5-6D813F66C0D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52F14439-F1F6-4036-B3A5-6D813F66C0D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52F14439-F1F6-4036-B3A5-6D813F66C0D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52F14439-F1F6-4036-B3A5-6D813F66C0D4}.Release|Any CPU.Build.0 = Release|Any CPU + {64B3BFDB-695D-462E-9879-9B0988802703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64B3BFDB-695D-462E-9879-9B0988802703}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64B3BFDB-695D-462E-9879-9B0988802703}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64B3BFDB-695D-462E-9879-9B0988802703}.Release|Any CPU.Build.0 = Release|Any CPU + {D2C1FD2B-68A6-4585-807E-27516D43019A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2C1FD2B-68A6-4585-807E-27516D43019A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2C1FD2B-68A6-4585-807E-27516D43019A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2C1FD2B-68A6-4585-807E-27516D43019A}.Release|Any CPU.Build.0 = Release|Any CPU + {313951FD-307C-4CE4-ADC5-09B1EFFFFF43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {313951FD-307C-4CE4-ADC5-09B1EFFFFF43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {313951FD-307C-4CE4-ADC5-09B1EFFFFF43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {313951FD-307C-4CE4-ADC5-09B1EFFFFF43}.Release|Any CPU.Build.0 = Release|Any CPU + {AFC9C0A9-C670-4881-AD76-72348D661A53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFC9C0A9-C670-4881-AD76-72348D661A53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFC9C0A9-C670-4881-AD76-72348D661A53}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFC9C0A9-C670-4881-AD76-72348D661A53}.Release|Any CPU.Build.0 = Release|Any CPU + {D5503E7F-10E7-4BA1-9611-86E92FC19ACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5503E7F-10E7-4BA1-9611-86E92FC19ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5503E7F-10E7-4BA1-9611-86E92FC19ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5503E7F-10E7-4BA1-9611-86E92FC19ACD}.Release|Any CPU.Build.0 = Release|Any CPU + {0794E6DA-609E-46A8-8C98-B7EC56152BD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0794E6DA-609E-46A8-8C98-B7EC56152BD4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0794E6DA-609E-46A8-8C98-B7EC56152BD4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0794E6DA-609E-46A8-8C98-B7EC56152BD4}.Release|Any CPU.Build.0 = Release|Any CPU + {FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982}.Release|Any CPU.Build.0 = Release|Any CPU + {E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4}.Release|Any CPU.Build.0 = Release|Any CPU + {CE84DF60-05F9-4FC5-9604-DD52568ECAA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE84DF60-05F9-4FC5-9604-DD52568ECAA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE84DF60-05F9-4FC5-9604-DD52568ECAA9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE84DF60-05F9-4FC5-9604-DD52568ECAA9}.Release|Any CPU.Build.0 = Release|Any CPU + {DEE17491-8C3E-44EA-A6FF-90C5132A3946}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEE17491-8C3E-44EA-A6FF-90C5132A3946}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEE17491-8C3E-44EA-A6FF-90C5132A3946}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEE17491-8C3E-44EA-A6FF-90C5132A3946}.Release|Any CPU.Build.0 = Release|Any CPU + {415B25B5-436E-45EC-A3E3-B660E172CC87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {415B25B5-436E-45EC-A3E3-B660E172CC87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {415B25B5-436E-45EC-A3E3-B660E172CC87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {415B25B5-436E-45EC-A3E3-B660E172CC87}.Release|Any CPU.Build.0 = Release|Any CPU + {C4D3CDF4-700E-49EB-B729-1C22291C67BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4D3CDF4-700E-49EB-B729-1C22291C67BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4D3CDF4-700E-49EB-B729-1C22291C67BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4D3CDF4-700E-49EB-B729-1C22291C67BE}.Release|Any CPU.Build.0 = Release|Any CPU + {4A1EE116-C6A2-4FDE-B428-F0F0583EBF57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A1EE116-C6A2-4FDE-B428-F0F0583EBF57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A1EE116-C6A2-4FDE-B428-F0F0583EBF57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A1EE116-C6A2-4FDE-B428-F0F0583EBF57}.Release|Any CPU.Build.0 = Release|Any CPU + {2359695D-AD50-4522-81C9-FD11ADBBCB47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2359695D-AD50-4522-81C9-FD11ADBBCB47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2359695D-AD50-4522-81C9-FD11ADBBCB47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2359695D-AD50-4522-81C9-FD11ADBBCB47}.Release|Any CPU.Build.0 = Release|Any CPU + {A7E8E59A-7BAB-4846-9C96-96A2BDB966AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7E8E59A-7BAB-4846-9C96-96A2BDB966AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7E8E59A-7BAB-4846-9C96-96A2BDB966AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7E8E59A-7BAB-4846-9C96-96A2BDB966AD}.Release|Any CPU.Build.0 = Release|Any CPU + {FA448F88-C24C-4AAB-A12A-49B2F5280993}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA448F88-C24C-4AAB-A12A-49B2F5280993}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA448F88-C24C-4AAB-A12A-49B2F5280993}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA448F88-C24C-4AAB-A12A-49B2F5280993}.Release|Any CPU.Build.0 = Release|Any CPU + {615D089B-EDB7-4978-9073-44D8A28D356E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {615D089B-EDB7-4978-9073-44D8A28D356E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {615D089B-EDB7-4978-9073-44D8A28D356E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {615D089B-EDB7-4978-9073-44D8A28D356E}.Release|Any CPU.Build.0 = Release|Any CPU + {9A90751B-C662-4D88-B789-87FCE7254B08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A90751B-C662-4D88-B789-87FCE7254B08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A90751B-C662-4D88-B789-87FCE7254B08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A90751B-C662-4D88-B789-87FCE7254B08}.Release|Any CPU.Build.0 = Release|Any CPU + {998CA6F9-C932-4DE9-85D2-8A695D213014}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {998CA6F9-C932-4DE9-85D2-8A695D213014}.Debug|Any CPU.Build.0 = Debug|Any CPU + {998CA6F9-C932-4DE9-85D2-8A695D213014}.Release|Any CPU.ActiveCfg = Release|Any CPU + {998CA6F9-C932-4DE9-85D2-8A695D213014}.Release|Any CPU.Build.0 = Release|Any CPU + {4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B}.Release|Any CPU.Build.0 = Release|Any CPU + {D4AA9009-BE4B-4B10-AA0E-9019C7489117}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4AA9009-BE4B-4B10-AA0E-9019C7489117}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4AA9009-BE4B-4B10-AA0E-9019C7489117}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4AA9009-BE4B-4B10-AA0E-9019C7489117}.Release|Any CPU.Build.0 = Release|Any CPU + {C81EBA83-0ACB-4274-9026-E2B750F9AF9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C81EBA83-0ACB-4274-9026-E2B750F9AF9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C81EBA83-0ACB-4274-9026-E2B750F9AF9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C81EBA83-0ACB-4274-9026-E2B750F9AF9B}.Release|Any CPU.Build.0 = Release|Any CPU + {C961A0A1-CFCD-4862-99B5-A8F41B52A421}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C961A0A1-CFCD-4862-99B5-A8F41B52A421}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C961A0A1-CFCD-4862-99B5-A8F41B52A421}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C961A0A1-CFCD-4862-99B5-A8F41B52A421}.Release|Any CPU.Build.0 = Release|Any CPU + {FDD154FB-5F44-4070-9663-7A650545765E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDD154FB-5F44-4070-9663-7A650545765E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDD154FB-5F44-4070-9663-7A650545765E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDD154FB-5F44-4070-9663-7A650545765E}.Release|Any CPU.Build.0 = Release|Any CPU + {B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D}.Release|Any CPU.Build.0 = Release|Any CPU + {17894AD0-C26D-4B76-87C5-773A7636B962}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17894AD0-C26D-4B76-87C5-773A7636B962}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17894AD0-C26D-4B76-87C5-773A7636B962}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17894AD0-C26D-4B76-87C5-773A7636B962}.Release|Any CPU.Build.0 = Release|Any CPU + {FA96F2DA-B4E8-410B-83CF-EE16DF425C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA96F2DA-B4E8-410B-83CF-EE16DF425C02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA96F2DA-B4E8-410B-83CF-EE16DF425C02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA96F2DA-B4E8-410B-83CF-EE16DF425C02}.Release|Any CPU.Build.0 = Release|Any CPU + {E8C72788-C72F-423D-83E2-8D101153D4A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8C72788-C72F-423D-83E2-8D101153D4A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8C72788-C72F-423D-83E2-8D101153D4A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8C72788-C72F-423D-83E2-8D101153D4A5}.Release|Any CPU.Build.0 = Release|Any CPU + {E0F26476-006F-429B-93AF-4EAE724EF1F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0F26476-006F-429B-93AF-4EAE724EF1F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0F26476-006F-429B-93AF-4EAE724EF1F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0F26476-006F-429B-93AF-4EAE724EF1F4}.Release|Any CPU.Build.0 = Release|Any CPU + {0ED52629-B128-4B2B-B3F4-FBA5B6432C93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0ED52629-B128-4B2B-B3F4-FBA5B6432C93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0ED52629-B128-4B2B-B3F4-FBA5B6432C93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0ED52629-B128-4B2B-B3F4-FBA5B6432C93}.Release|Any CPU.Build.0 = Release|Any CPU + {56BD5D23-B7D0-430C-ADBE-E91832699C05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56BD5D23-B7D0-430C-ADBE-E91832699C05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56BD5D23-B7D0-430C-ADBE-E91832699C05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56BD5D23-B7D0-430C-ADBE-E91832699C05}.Release|Any CPU.Build.0 = Release|Any CPU + {3FE31761-5C82-4487-9839-ED013BEBC184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FE31761-5C82-4487-9839-ED013BEBC184}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FE31761-5C82-4487-9839-ED013BEBC184}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FE31761-5C82-4487-9839-ED013BEBC184}.Release|Any CPU.Build.0 = Release|Any CPU + {DAD0BB78-83EB-4A71-8A18-113635AB5B90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DAD0BB78-83EB-4A71-8A18-113635AB5B90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DAD0BB78-83EB-4A71-8A18-113635AB5B90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DAD0BB78-83EB-4A71-8A18-113635AB5B90}.Release|Any CPU.Build.0 = Release|Any CPU + {BB2BCA75-3CCF-4499-8C12-0C9E3183045D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB2BCA75-3CCF-4499-8C12-0C9E3183045D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB2BCA75-3CCF-4499-8C12-0C9E3183045D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB2BCA75-3CCF-4499-8C12-0C9E3183045D}.Release|Any CPU.Build.0 = Release|Any CPU + {A34518E3-A4E8-4BBB-9FCA-E875F2030BA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A34518E3-A4E8-4BBB-9FCA-E875F2030BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A34518E3-A4E8-4BBB-9FCA-E875F2030BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A34518E3-A4E8-4BBB-9FCA-E875F2030BA6}.Release|Any CPU.Build.0 = Release|Any CPU + {8B636688-5CB1-47E6-AC8B-B6731E3E3F0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B636688-5CB1-47E6-AC8B-B6731E3E3F0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B636688-5CB1-47E6-AC8B-B6731E3E3F0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B636688-5CB1-47E6-AC8B-B6731E3E3F0A}.Release|Any CPU.Build.0 = Release|Any CPU + {EBACC7BC-9151-45B0-88DE-C118298FBDCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBACC7BC-9151-45B0-88DE-C118298FBDCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBACC7BC-9151-45B0-88DE-C118298FBDCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBACC7BC-9151-45B0-88DE-C118298FBDCB}.Release|Any CPU.Build.0 = Release|Any CPU + {C103E27F-1D27-4200-9647-4396CF0916FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C103E27F-1D27-4200-9647-4396CF0916FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C103E27F-1D27-4200-9647-4396CF0916FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C103E27F-1D27-4200-9647-4396CF0916FE}.Release|Any CPU.Build.0 = Release|Any CPU + {F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0}.Release|Any CPU.Build.0 = Release|Any CPU + {203670E5-177B-46DC-B1E7-E529147A2FB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {203670E5-177B-46DC-B1E7-E529147A2FB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {203670E5-177B-46DC-B1E7-E529147A2FB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {203670E5-177B-46DC-B1E7-E529147A2FB1}.Release|Any CPU.Build.0 = Release|Any CPU + {DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5}.Release|Any CPU.Build.0 = Release|Any CPU + {4823D587-E11A-49D8-A798-5D34129EAC8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4823D587-E11A-49D8-A798-5D34129EAC8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4823D587-E11A-49D8-A798-5D34129EAC8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4823D587-E11A-49D8-A798-5D34129EAC8A}.Release|Any CPU.Build.0 = Release|Any CPU + {9062925D-497D-4B4C-885A-BF88001A1E5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9062925D-497D-4B4C-885A-BF88001A1E5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9062925D-497D-4B4C-885A-BF88001A1E5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9062925D-497D-4B4C-885A-BF88001A1E5A}.Release|Any CPU.Build.0 = Release|Any CPU + {8CD782B3-2075-4F65-860A-CDF962B2B51C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CD782B3-2075-4F65-860A-CDF962B2B51C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CD782B3-2075-4F65-860A-CDF962B2B51C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CD782B3-2075-4F65-860A-CDF962B2B51C}.Release|Any CPU.Build.0 = Release|Any CPU + {08882BEC-26B6-4994-A6DD-A6066AA07CAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08882BEC-26B6-4994-A6DD-A6066AA07CAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08882BEC-26B6-4994-A6DD-A6066AA07CAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08882BEC-26B6-4994-A6DD-A6066AA07CAF}.Release|Any CPU.Build.0 = Release|Any CPU + {5284251C-2245-4BE9-9A7A-E014D2704763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5284251C-2245-4BE9-9A7A-E014D2704763}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5284251C-2245-4BE9-9A7A-E014D2704763}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5284251C-2245-4BE9-9A7A-E014D2704763}.Release|Any CPU.Build.0 = Release|Any CPU + {38C7E337-91B3-4075-B3B6-7BCC9D5B87FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38C7E337-91B3-4075-B3B6-7BCC9D5B87FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38C7E337-91B3-4075-B3B6-7BCC9D5B87FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38C7E337-91B3-4075-B3B6-7BCC9D5B87FA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5054DD25-B14D-4A6D-A349-951C323BD564} = {AC15A422-22C5-46C7-A9F4-3473AB68E8BE} + {CD058825-516B-45F8-8C98-640C58D6F0FC} = {5054DD25-B14D-4A6D-A349-951C323BD564} + {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} = {AC15A422-22C5-46C7-A9F4-3473AB68E8BE} + {59481001-CCA3-468E-BBD4-952B3901F4CC} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {5E238C52-6BB3-49FE-B1E5-DEE00F28E7A2} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {AF9C1990-2581-449B-BC07-95E07ABD8C5B} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {6DA9C00A-D2B7-4E30-9543-CD4C088A2092} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {D02D6036-1987-4905-A996-3B030FDABDDE} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {CB0F3968-47D0-4C65-8962-29CF9703E066} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {0FF6765F-54E9-431F-8AE9-D6130ED45E34} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {A0743EF6-D719-4D0C-8506-ED0E68AF8AAE} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {604F8AB9-AEA8-4B53-8358-94610E6C70E9} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {E6BF0450-C60D-4C68-B204-91E6AF64288B} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {CA8E46FF-9F59-46AE-BC46-A36047FCF64A} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {17A943D1-9083-437A-8E45-449F54F2B67A} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {52F14439-F1F6-4036-B3A5-6D813F66C0D4} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {64B3BFDB-695D-462E-9879-9B0988802703} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {D2C1FD2B-68A6-4585-807E-27516D43019A} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {313951FD-307C-4CE4-ADC5-09B1EFFFFF43} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {AFC9C0A9-C670-4881-AD76-72348D661A53} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {D5503E7F-10E7-4BA1-9611-86E92FC19ACD} = {AC8D1CE6-BAF8-4A05-8F87-232598A756C3} + {F397611D-54BB-4ED3-BB4F-C34BE9308B40} = {7528202E-DFFE-4295-BAED-6F40D8D62864} + {0794E6DA-609E-46A8-8C98-B7EC56152BD4} = {F397611D-54BB-4ED3-BB4F-C34BE9308B40} + {BCCDF699-8F57-4E28-BEF8-014770B52DD2} = {AC15A422-22C5-46C7-A9F4-3473AB68E8BE} + {6025FFB1-0237-4657-A450-1479829D5892} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {FBFAC8AD-5DD7-4C55-9EAC-8FF3D68E6982} = {6025FFB1-0237-4657-A450-1479829D5892} + {E3D1BBAE-C4E9-425F-9EAD-A1CF539A0ED4} = {6025FFB1-0237-4657-A450-1479829D5892} + {603E0CDC-19F8-4201-8B7A-2D048FFE0415} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {CE84DF60-05F9-4FC5-9604-DD52568ECAA9} = {603E0CDC-19F8-4201-8B7A-2D048FFE0415} + {DEE17491-8C3E-44EA-A6FF-90C5132A3946} = {603E0CDC-19F8-4201-8B7A-2D048FFE0415} + {9454DA13-BA5A-40CE-AB43-67D15CD6D7A8} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {415B25B5-436E-45EC-A3E3-B660E172CC87} = {9454DA13-BA5A-40CE-AB43-67D15CD6D7A8} + {C4D3CDF4-700E-49EB-B729-1C22291C67BE} = {9454DA13-BA5A-40CE-AB43-67D15CD6D7A8} + {4B022AC7-B135-4D3F-92C3-F877DC8E9240} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {4A1EE116-C6A2-4FDE-B428-F0F0583EBF57} = {4B022AC7-B135-4D3F-92C3-F877DC8E9240} + {2359695D-AD50-4522-81C9-FD11ADBBCB47} = {4B022AC7-B135-4D3F-92C3-F877DC8E9240} + {45BB845E-7B34-43A8-8251-93B9238E1B21} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {A7E8E59A-7BAB-4846-9C96-96A2BDB966AD} = {45BB845E-7B34-43A8-8251-93B9238E1B21} + {FA448F88-C24C-4AAB-A12A-49B2F5280993} = {45BB845E-7B34-43A8-8251-93B9238E1B21} + {98CC543B-2C97-4ECC-802D-D09FFD051CB8} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {615D089B-EDB7-4978-9073-44D8A28D356E} = {98CC543B-2C97-4ECC-802D-D09FFD051CB8} + {9A90751B-C662-4D88-B789-87FCE7254B08} = {98CC543B-2C97-4ECC-802D-D09FFD051CB8} + {1FB16AE8-2E95-40A0-B402-CEF47CAE84ED} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {998CA6F9-C932-4DE9-85D2-8A695D213014} = {1FB16AE8-2E95-40A0-B402-CEF47CAE84ED} + {4BFA9551-38BA-4160-8D42-AF6FC4FB1F8B} = {1FB16AE8-2E95-40A0-B402-CEF47CAE84ED} + {3DA1CB7D-1B79-411F-B077-18CB8B9D93FD} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {D4AA9009-BE4B-4B10-AA0E-9019C7489117} = {3DA1CB7D-1B79-411F-B077-18CB8B9D93FD} + {C81EBA83-0ACB-4274-9026-E2B750F9AF9B} = {3DA1CB7D-1B79-411F-B077-18CB8B9D93FD} + {226F5499-F077-4B20-B7EB-AF2830B26A05} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {C961A0A1-CFCD-4862-99B5-A8F41B52A421} = {226F5499-F077-4B20-B7EB-AF2830B26A05} + {FDD154FB-5F44-4070-9663-7A650545765E} = {226F5499-F077-4B20-B7EB-AF2830B26A05} + {2F1A88DC-D97F-4E61-8BC4-5F89A1034CFA} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {B37B5E64-62B0-469D-A6C8-A7B4E98F1F8D} = {2F1A88DC-D97F-4E61-8BC4-5F89A1034CFA} + {17894AD0-C26D-4B76-87C5-773A7636B962} = {2F1A88DC-D97F-4E61-8BC4-5F89A1034CFA} + {2454BC26-666F-4B65-80C5-EE209F15E1CF} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {FA96F2DA-B4E8-410B-83CF-EE16DF425C02} = {2454BC26-666F-4B65-80C5-EE209F15E1CF} + {E8C72788-C72F-423D-83E2-8D101153D4A5} = {2454BC26-666F-4B65-80C5-EE209F15E1CF} + {BD39BCFF-EA9E-4831-8F40-4CAFBA18119E} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {E0F26476-006F-429B-93AF-4EAE724EF1F4} = {BD39BCFF-EA9E-4831-8F40-4CAFBA18119E} + {0ED52629-B128-4B2B-B3F4-FBA5B6432C93} = {BD39BCFF-EA9E-4831-8F40-4CAFBA18119E} + {45C8D293-893F-4F15-9F51-5242EC89D69B} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {56BD5D23-B7D0-430C-ADBE-E91832699C05} = {45C8D293-893F-4F15-9F51-5242EC89D69B} + {3FE31761-5C82-4487-9839-ED013BEBC184} = {45C8D293-893F-4F15-9F51-5242EC89D69B} + {1634E768-03DD-4845-BACD-746A1CABC235} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {DAD0BB78-83EB-4A71-8A18-113635AB5B90} = {1634E768-03DD-4845-BACD-746A1CABC235} + {7C2EF356-A977-4634-A7CC-FF4305DF9528} = {BCCDF699-8F57-4E28-BEF8-014770B52DD2} + {BB2BCA75-3CCF-4499-8C12-0C9E3183045D} = {7C2EF356-A977-4634-A7CC-FF4305DF9528} + {AD26F940-D9E4-40A6-8625-5AE210511482} = {7528202E-DFFE-4295-BAED-6F40D8D62864} + {CFDD8FEB-EB9C-4F5C-AAEE-002B8E4AEB87} = {AD26F940-D9E4-40A6-8625-5AE210511482} + {A34518E3-A4E8-4BBB-9FCA-E875F2030BA6} = {CFDD8FEB-EB9C-4F5C-AAEE-002B8E4AEB87} + {738344E5-5CBE-4C37-AE52-90084CEACFC4} = {7528202E-DFFE-4295-BAED-6F40D8D62864} + {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} = {738344E5-5CBE-4C37-AE52-90084CEACFC4} + {81014D1C-5EEB-4583-969B-D5A38A591235} = {738344E5-5CBE-4C37-AE52-90084CEACFC4} + {A9B8E9D0-8E3C-4495-B8FE-CBBAE3D46E62} = {738344E5-5CBE-4C37-AE52-90084CEACFC4} + {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} = {738344E5-5CBE-4C37-AE52-90084CEACFC4} + {8B636688-5CB1-47E6-AC8B-B6731E3E3F0A} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {EBACC7BC-9151-45B0-88DE-C118298FBDCB} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {C103E27F-1D27-4200-9647-4396CF0916FE} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {F8BC9C1B-2094-4894-AF0E-22D6CDE7EEB0} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {203670E5-177B-46DC-B1E7-E529147A2FB1} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {DFA543B1-0E0A-4BB8-AEBC-0D209F4D81B5} = {0512E4C6-76E9-4DA4-8881-D1D6695E48F5} + {4823D587-E11A-49D8-A798-5D34129EAC8A} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + {9062925D-497D-4B4C-885A-BF88001A1E5A} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + {8CD782B3-2075-4F65-860A-CDF962B2B51C} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + {08882BEC-26B6-4994-A6DD-A6066AA07CAF} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + {5284251C-2245-4BE9-9A7A-E014D2704763} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + {38C7E337-91B3-4075-B3B6-7BCC9D5B87FA} = {FD68316B-FCDA-4241-8A32-2B2C0863CE7D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AD0585D6-CBA4-4818-86D8-0D914F18E390} + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json index 5bf3e71b..d92fc07e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "7.0.200", + "version": "8.0.303", "rollForward": "latestFeature" } -} +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..6ce97590 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 4b2ea7a2..85e44f62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,49 +1,52 @@ { - "name": "ecommerce-microservices", + "name": "food-delivery-microservices", "version": "1.0.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ecommerce-microservices", + "name": "food-delivery-microservices", "version": "1.0.0", "license": "MIT", "devDependencies": { - "@commitlint/cli": "^17.4.2", - "@commitlint/config-conventional": "^17.4.2", - "husky": "^8.0.3" + "@commitlint/cli": "^19.2.1", + "@commitlint/config-conventional": "^19.1.0", + "husky": "^9.0.11", + "npm-check-updates": "^16.14.18" } }, "node_modules/@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -90,83 +93,70 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, + "optional": true, "engines": { - "node": ">=4" + "node": ">=0.1.90" } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@commitlint/cli": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.2.1.tgz", + "integrity": "sha512-cbkYUJsLqRomccNxvoJTyv5yn0bSy05BBizVyIcLACkRbVUqYorC351Diw/XFSWC/GtpwiwT2eOvQgFZa374bg==", "dev": true, "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@commitlint/cli": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-17.4.2.tgz", - "integrity": "sha512-0rPGJ2O1owhpxMIXL9YJ2CgPkdrFLKZElIZHXDN8L8+qWK1DGH7Q7IelBT1pchXTYTuDlqkOTdh//aTvT3bSUA==", - "dev": true, - "dependencies": { - "@commitlint/format": "^17.4.0", - "@commitlint/lint": "^17.4.2", - "@commitlint/load": "^17.4.2", - "@commitlint/read": "^17.4.2", - "@commitlint/types": "^17.4.0", - "execa": "^5.0.0", - "lodash.isfunction": "^3.0.9", - "resolve-from": "5.0.0", - "resolve-global": "1.0.0", + "@commitlint/format": "^19.0.3", + "@commitlint/lint": "^19.1.0", + "@commitlint/load": "^19.2.0", + "@commitlint/read": "^19.2.1", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", "yargs": "^17.0.0" }, "bin": { "commitlint": "cli.js" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/config-conventional": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-17.4.2.tgz", - "integrity": "sha512-JVo1moSj5eDMoql159q8zKCU8lkOhQ+b23Vl3LVVrS6PXDLQIELnJ34ChQmFVbBdSSRNAbbXnRDhosFU+wnuHw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.1.0.tgz", + "integrity": "sha512-KIKD2xrp6Uuk+dcZVj3++MlzIr/Su6zLE8crEDQCZNvWHNQSeeGbzOlNtsR32TUy6H3JbP7nWgduAHCaiGQ6EA==", "dev": true, "dependencies": { - "conventional-changelog-conventionalcommits": "^5.0.0" + "@commitlint/types": "^19.0.3", + "conventional-changelog-conventionalcommits": "^7.0.2" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/config-validator": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-17.4.0.tgz", - "integrity": "sha512-Sa/+8KNpDXz4zT4bVbz2fpFjvgkPO6u2V2fP4TKgt6FjmOw2z3eEX859vtfeaTav/ukBw0/0jr+5ZTZp9zCBhA==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.0.3.tgz", + "integrity": "sha512-2D3r4PKjoo59zBc2auodrSCaUnCSALCx54yveOFwwP/i2kfEAQrygwOleFWswLqK0UL/F9r07MFi5ev2ohyM4Q==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.0", + "@commitlint/types": "^19.0.3", "ajv": "^8.11.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/ensure": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-17.4.0.tgz", - "integrity": "sha512-7oAxt25je0jeQ/E0O/M8L3ADb1Cvweu/5lc/kYF8g/kXatI0wxGE5La52onnAUAWeWlsuvBNar15WcrmDmr5Mw==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.0.3.tgz", + "integrity": "sha512-SZEpa/VvBLoT+EFZVb91YWbmaZ/9rPH3ESrINOl0HD2kMYsjvl0tF7nMHh0EpTcv4+gTtZBAe1y/SS6/OhfZzQ==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.0", + "@commitlint/types": "^19.0.3", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", @@ -174,1866 +164,1889 @@ "lodash.upperfirst": "^4.3.1" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/execute-rule": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-17.4.0.tgz", - "integrity": "sha512-LIgYXuCSO5Gvtc0t9bebAMSwd68ewzmqLypqI2Kke1rqOqqDbMpYcYfoPfFlv9eyLIh4jocHWwCK5FS7z9icUA==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.0.0.tgz", + "integrity": "sha512-mtsdpY1qyWgAO/iOK0L6gSGeR7GFcdW7tIjcNFxcWkfLDF5qVbPHKuGATFqRMsxcO8OUKNj0+3WOHB7EHm4Jdw==", "dev": true, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/format": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-17.4.0.tgz", - "integrity": "sha512-Z2bWAU5+f1YZh9W76c84J8iLIWIvvm+mzqogTz0Nsc1x6EHW0Z2gI38g5HAjB0r0I3ZjR15IDEJKhsxyblcyhA==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.0.3.tgz", + "integrity": "sha512-QjjyGyoiVWzx1f5xOteKHNLFyhyweVifMgopozSgx1fGNrGV8+wp7k6n1t6StHdJ6maQJ+UUtO2TcEiBFRyR6Q==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.0", - "chalk": "^4.1.0" + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/is-ignored": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-17.4.2.tgz", - "integrity": "sha512-1b2Y2qJ6n7bHG9K6h8S4lBGUl6kc7mMhJN9gy1SQfUZqe92ToDjUTtgNWb6LbzR1X8Cq4SEus4VU8Z/riEa94Q==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.0.3.tgz", + "integrity": "sha512-MqDrxJaRSVSzCbPsV6iOKG/Lt52Y+PVwFVexqImmYYFhe51iVJjK2hRhOG2jUAGiUHk4jpdFr0cZPzcBkSzXDQ==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.0", - "semver": "7.3.8" + "@commitlint/types": "^19.0.3", + "semver": "^7.6.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/lint": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-17.4.2.tgz", - "integrity": "sha512-HcymabrdBhsDMNzIv146+ZPNBPBK5gMNsVH+el2lCagnYgCi/4ixrHooeVyS64Fgce2K26+MC7OQ4vVH8wQWVw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.1.0.tgz", + "integrity": "sha512-ESjaBmL/9cxm+eePyEr6SFlBUIYlYpI80n+Ltm7IA3MAcrmiP05UMhJdAD66sO8jvo8O4xdGn/1Mt2G5VzfZKw==", "dev": true, "dependencies": { - "@commitlint/is-ignored": "^17.4.2", - "@commitlint/parse": "^17.4.2", - "@commitlint/rules": "^17.4.2", - "@commitlint/types": "^17.4.0" + "@commitlint/is-ignored": "^19.0.3", + "@commitlint/parse": "^19.0.3", + "@commitlint/rules": "^19.0.3", + "@commitlint/types": "^19.0.3" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/load": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-17.4.2.tgz", - "integrity": "sha512-Si++F85rJ9t4hw6JcOw1i2h0fdpdFQt0YKwjuK4bk9KhFjyFkRxvR3SB2dPaMs+EwWlDrDBGL+ygip1QD6gmPw==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.2.0.tgz", + "integrity": "sha512-XvxxLJTKqZojCxaBQ7u92qQLFMMZc4+p9qrIq/9kJDy8DOrEa7P1yx7Tjdc2u2JxIalqT4KOGraVgCE7eCYJyQ==", "dev": true, "dependencies": { - "@commitlint/config-validator": "^17.4.0", - "@commitlint/execute-rule": "^17.4.0", - "@commitlint/resolve-extends": "^17.4.0", - "@commitlint/types": "^17.4.0", - "@types/node": "*", - "chalk": "^4.1.0", - "cosmiconfig": "^8.0.0", - "cosmiconfig-typescript-loader": "^4.0.0", + "@commitlint/config-validator": "^19.0.3", + "@commitlint/execute-rule": "^19.0.0", + "@commitlint/resolve-extends": "^19.1.0", + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^5.0.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0", - "resolve-from": "^5.0.0", - "ts-node": "^10.8.1", - "typescript": "^4.6.4" + "lodash.uniq": "^4.5.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/message": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-17.4.2.tgz", - "integrity": "sha512-3XMNbzB+3bhKA1hSAWPCQA3lNxR4zaeQAQcHj0Hx5sVdO6ryXtgUBGGv+1ZCLMgAPRixuc6en+iNAzZ4NzAa8Q==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.0.0.tgz", + "integrity": "sha512-c9czf6lU+9oF9gVVa2lmKaOARJvt4soRsVmbR7Njwp9FpbBgste5i7l/2l5o8MmbwGh4yE1snfnsy2qyA2r/Fw==", "dev": true, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/parse": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-17.4.2.tgz", - "integrity": "sha512-DK4EwqhxfXpyCA+UH8TBRIAXAfmmX4q9QRBz/2h9F9sI91yt6mltTrL6TKURMcjUVmgaB80wgS9QybNIyVBIJA==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.0.3.tgz", + "integrity": "sha512-Il+tNyOb8VDxN3P6XoBBwWJtKKGzHlitEuXA5BP6ir/3loWlsSqDr5aecl6hZcC/spjq4pHqNh0qPlfeWu38QA==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.0", - "conventional-changelog-angular": "^5.0.11", - "conventional-commits-parser": "^3.2.2" + "@commitlint/types": "^19.0.3", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/read": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-17.4.2.tgz", - "integrity": "sha512-hasYOdbhEg+W4hi0InmXHxtD/1favB4WdwyFxs1eOy/DvMw6+2IZBmATgGOlqhahsypk4kChhxjAFJAZ2F+JBg==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.2.1.tgz", + "integrity": "sha512-qETc4+PL0EUv7Q36lJbPG+NJiBOGg7SSC7B5BsPWOmei+Dyif80ErfWQ0qXoW9oCh7GTpTNRoaVhiI8RbhuaNw==", "dev": true, "dependencies": { - "@commitlint/top-level": "^17.4.0", - "@commitlint/types": "^17.4.0", - "fs-extra": "^11.0.0", - "git-raw-commits": "^2.0.0", - "minimist": "^1.2.6" + "@commitlint/top-level": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/resolve-extends": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-17.4.0.tgz", - "integrity": "sha512-3JsmwkrCzoK8sO22AzLBvNEvC1Pmdn/65RKXzEtQMy6oYMl0Snrq97a5bQQEFETF0VsvbtUuKttLqqgn99OXRQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.1.0.tgz", + "integrity": "sha512-z2riI+8G3CET5CPgXJPlzftH+RiWYLMYv4C9tSLdLXdr6pBNimSKukYP9MS27ejmscqCTVA4almdLh0ODD2KYg==", "dev": true, "dependencies": { - "@commitlint/config-validator": "^17.4.0", - "@commitlint/types": "^17.4.0", - "import-fresh": "^3.0.0", + "@commitlint/config-validator": "^19.0.3", + "@commitlint/types": "^19.0.3", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0" + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/rules": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-17.4.2.tgz", - "integrity": "sha512-OGrPsMb9Fx3/bZ64/EzJehY9YDSGWzp81Pj+zJiY+r/NSgJI3nUYdlS37jykNIugzazdEXfMtQ10kmA+Kx2pZQ==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.0.3.tgz", + "integrity": "sha512-TspKb9VB6svklxNCKKwxhELn7qhtY1rFF8ls58DcFd0F97XoG07xugPjjbVnLqmMkRjZDbDIwBKt9bddOfLaPw==", "dev": true, "dependencies": { - "@commitlint/ensure": "^17.4.0", - "@commitlint/message": "^17.4.2", - "@commitlint/to-lines": "^17.4.0", - "@commitlint/types": "^17.4.0", - "execa": "^5.0.0" + "@commitlint/ensure": "^19.0.3", + "@commitlint/message": "^19.0.0", + "@commitlint/to-lines": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/to-lines": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-17.4.0.tgz", - "integrity": "sha512-LcIy/6ZZolsfwDUWfN1mJ+co09soSuNASfKEU5sCmgFCvX5iHwRYLiIuoqXzOVDYOy7E7IcHilr/KS0e5T+0Hg==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.0.0.tgz", + "integrity": "sha512-vkxWo+VQU5wFhiP9Ub9Sre0FYe019JxFikrALVoD5UGa8/t3yOJEpEhxC5xKiENKKhUkTpEItMTRAjHw2SCpZw==", "dev": true, "engines": { - "node": ">=v14" + "node": ">=v18" } }, "node_modules/@commitlint/top-level": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-17.4.0.tgz", - "integrity": "sha512-/1loE/g+dTTQgHnjoCy0AexKAEFyHsR2zRB4NWrZ6lZSMIxAhBJnmCqwao7b4H8888PsfoTBCLBYIw8vGnej8g==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.0.0.tgz", + "integrity": "sha512-KKjShd6u1aMGNkCkaX4aG1jOGdn7f8ZI8TR1VEuNqUOjWTOdcDSsmglinglJ18JTjuBX5I1PtjrhQCRcixRVFQ==", "dev": true, "dependencies": { - "find-up": "^5.0.0" + "find-up": "^7.0.0" }, "engines": { - "node": ">=v14" + "node": ">=v18" } }, - "node_modules/@commitlint/types": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-17.4.0.tgz", - "integrity": "sha512-2NjAnq5IcxY9kXtUeO2Ac0aPpvkuOmwbH/BxIm36XXK5LtWFObWJWjXOA+kcaABMrthjWu6la+FUpyYFMHRvbA==", + "node_modules/@commitlint/top-level/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", "dev": true, "dependencies": { - "chalk": "^4.1.0" + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": ">=v14" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@commitlint/top-level/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", "dev": true, "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "p-locate": "^6.0.0" }, "engines": { - "node": ">=12" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "node_modules/@commitlint/top-level/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, "engines": { - "node": ">=6.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@commitlint/top-level/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true - }, - "node_modules/@types/minimist": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", - "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", - "dev": true - }, - "node_modules/@types/node": { - "version": "18.13.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", - "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==", - "dev": true - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", - "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" + "p-limit": "^4.0.0" }, "engines": { - "node": ">=0.4.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "node_modules/@commitlint/top-level/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", "dev": true, "engines": { - "node": ">=0.4.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/@commitlint/top-level/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "engines": { + "node": ">=12.20" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@commitlint/types": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.0.3.tgz", + "integrity": "sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=v18" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", "dev": true, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=7.0.0" + "node": ">= 8" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/conventional-changelog-angular": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", - "integrity": "sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==", + "node_modules/@npmcli/fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", + "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", "dev": true, "dependencies": { - "compare-func": "^2.0.0", - "q": "^1.5.1" + "semver": "^7.3.5" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz", - "integrity": "sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==", + "node_modules/@npmcli/git": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.1.0.tgz", + "integrity": "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==", "dev": true, "dependencies": { - "compare-func": "^2.0.0", - "lodash": "^4.17.15", - "q": "^1.5.1" + "@npmcli/promise-spawn": "^6.0.0", + "lru-cache": "^7.4.4", + "npm-pick-manifest": "^8.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^3.0.0" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/conventional-commits-parser": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.4.tgz", - "integrity": "sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==", + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", "dev": true, "dependencies": { - "is-text-path": "^1.0.1", - "JSONStream": "^1.0.4", - "lodash": "^4.17.15", - "meow": "^8.0.0", - "split2": "^3.0.0", - "through2": "^4.0.0" + "isexe": "^2.0.0" }, "bin": { - "conventional-commits-parser": "cli.js" + "node-which": "bin/which.js" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/cosmiconfig": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.0.0.tgz", - "integrity": "sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ==", + "node_modules/@npmcli/installed-package-contents": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", + "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", "dev": true, "dependencies": { - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0" + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "lib/index.js" }, "engines": { - "node": ">=14" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", - "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", "dev": true, - "engines": { - "node": ">=12", - "npm": ">=6" + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=7", - "ts-node": ">=10", - "typescript": ">=3" + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "node_modules/@npmcli/move-file/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/dargs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", - "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", + "node_modules/@npmcli/move-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, "engines": { - "node": ">=8" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "node_modules/@npmcli/move-file/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=0.10.0" + "node": "*" } }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" + "glob": "^7.1.3" }, - "engines": { - "node": ">=0.10.0" + "bin": { + "rimraf": "bin.js" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/decamelize-keys/node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "node_modules/@npmcli/promise-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz", + "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==", "dev": true, + "dependencies": { + "which": "^3.0.0" + }, "engines": { - "node": ">=0.3.1" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", "dev": true, "dependencies": { - "is-obj": "^2.0.0" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" }, "engines": { - "node": ">=8" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "node_modules/@npmcli/run-script": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-6.0.2.tgz", + "integrity": "sha512-NCcr1uQo1k5U+SYlnIrbAh3cxy+OQT1VtqiAbxdymSlptbzBb62AjH2xXgjNCoP073hoa1CfCAcwoZ8k96C4nA==", + "dev": true, + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/promise-spawn": "^6.0.0", + "node-gyp": "^9.0.0", + "read-package-json-fast": "^3.0.0", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", "dev": true, "dependencies": { - "is-arrayish": "^0.2.1" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, + "optional": true, "engines": { - "node": ">=6" + "node": ">=14" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "dev": true, "engines": { - "node": ">=0.8.0" + "node": ">=12.22.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "dev": true, "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "graceful-fs": "4.2.10" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=12.22.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "dev": true }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/@pnpm/npm-conf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", + "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", "dev": true, "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/fs-extra": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", - "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", + "node_modules/@sigstore/bundle": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", + "integrity": "sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==", "dev": true, "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@sigstore/protobuf-specs": "^0.2.0" }, "engines": { - "node": ">=14.14" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/@sigstore/protobuf-specs": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz", + "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/git-raw-commits": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", - "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", + "node_modules/@sigstore/sign": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-1.0.0.tgz", + "integrity": "sha512-INxFVNQteLtcfGmcoldzV6Je0sbbfh9I16DM4yJPw3j5+TFP8X6uIiA18mvpEa9yyeycAKgPmOA3X9hVdVTPUA==", "dev": true, "dependencies": { - "dargs": "^7.0.0", - "lodash": "^4.17.15", - "meow": "^8.0.0", - "split2": "^3.0.0", - "through2": "^4.0.0" - }, - "bin": { - "git-raw-commits": "cli.js" + "@sigstore/bundle": "^1.1.0", + "@sigstore/protobuf-specs": "^0.2.0", + "make-fetch-happen": "^11.0.1" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "node_modules/@sigstore/tuf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-1.0.3.tgz", + "integrity": "sha512-2bRovzs0nJZFlCN3rXirE4gwxCn97JNjMmwpecqlbgV9WcxX7WRuIrgzx/X7Ib7MYRbyUTpBYE0s2x6AmZXnlg==", "dev": true, "dependencies": { - "ini": "^1.3.4" + "@sigstore/protobuf-specs": "^0.2.0", + "tuf-js": "^1.1.7" }, "engines": { - "node": ">=4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true - }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", "dev": true, "engines": { - "node": ">=6" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", "dev": true, "dependencies": { - "function-bind": "^1.1.1" + "defer-to-connect": "^2.0.1" }, "engines": { - "node": ">= 0.4.0" + "node": ">=14.16" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true, "engines": { - "node": ">=8" + "node": ">= 10" } }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "node_modules/@tufjs/canonical-json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", + "integrity": "sha512-QTnf++uxunWvG2z3UFNzAoQPHxnSXOwtaI3iJ+AohhV+5vONuArPjJE7aPXPVXfXJsqrVbZBu9b81AJoSd09IQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-1.0.4.tgz", + "integrity": "sha512-qaGV9ltJP0EO25YfFUPhxRVK0evXFIAGicsVXuRim4Ed9cjPxYhNnNJ49SFmbeLgtxpslIkX317IgpfcHPVj/A==", "dev": true, "dependencies": { - "lru-cache": "^6.0.0" + "@tufjs/canonical-json": "1.0.0", + "minimatch": "^9.0.0" }, "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==", "dev": true, - "engines": { - "node": ">=10.17.0" + "dependencies": { + "@types/node": "*" } }, - "node_modules/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.4.tgz", + "integrity": "sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==", "dev": true, - "bin": { - "husky": "lib/bin.js" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "dependencies": { + "undici-types": "~5.26.4" } }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "node_modules/@types/semver-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/semver-utils/-/semver-utils-1.1.3.tgz", + "integrity": "sha512-T+YwkslhsM+CeuhYUxyAjWm7mJ5am/K10UX40RuA6k6Lc7eGtq8iY2xOzy7Vq0GOqhl/xZl5l2FwURZMTPTUww==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "debug": "4" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6.0.0" } }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", "dev": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, "engines": { - "node": ">=4" + "node": ">= 8.0.0" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "dependencies": { - "has": "^1.0.3" + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "string-width": "^4.1.0" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "engines": { "node": ">=8" } }, - "node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-text-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "dev": true, "dependencies": { - "text-extensions": "^1.0.0" + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" }, "engines": { - "node": ">=0.10.0" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=8" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", "dev": true, "dependencies": { - "universalify": "^2.0.0" + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ] - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", "dev": true, - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, "engines": { - "node": "*" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/boxen/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/boxen/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "dependencies": { - "p-locate": "^5.0.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true - }, - "node_modules/lodash.isfunction": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", - "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/meow": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", - "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "dependencies": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" + "fill-range": "^7.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/builtins": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", + "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", "dev": true, - "engines": { - "node": ">=6" + "dependencies": { + "semver": "^7.0.0" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "node_modules/cacache": { + "version": "17.1.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.1.4.tgz", + "integrity": "sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==", "dev": true, + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^7.7.1", + "minipass": "^7.0.3", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, "engines": { - "node": ">=4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=12" } }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "node_modules/cacache/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, "engines": { - "node": ">= 6" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", "dev": true, - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, "engines": { - "node": ">=10" + "node": ">=14.16" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", "dev": true, "dependencies": { - "path-key": "^3.0.0" + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.16" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, "engines": { "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "engines": { + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, "engines": { "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/cli-table3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", + "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", "dev": true, + "dependencies": { + "string-width": "^4.2.0" + }, "engines": { - "node": ">=8" + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=8" + "node": ">=7.0.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "dev": true, - "engines": { - "node": ">=8" + "bin": { + "color-support": "bin.js" } }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "engines": { - "node": ">=6" + "node": ">=14" } }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" } }, - "node_modules/quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" } }, - "node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/configstore": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", + "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", "dev": true, "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" + "dot-prop": "^6.0.1", + "graceful-fs": "^4.2.6", + "unique-string": "^3.0.0", + "write-file-atomic": "^3.0.3", + "xdg-basedir": "^5.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/yeoman/configstore?sponsor=1" } }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "node_modules/configstore/node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", "dev": true, "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" + "is-obj": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", "dev": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "compare-func": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" } }, - "node_modules/read-pkg-up/node_modules/locate-path": { + "node_modules/conventional-commits-parser": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", "dev": true, "dependencies": { - "p-locate": "^4.1.0" + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" }, "engines": { - "node": ">=8" + "node": ">=16" } }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "dependencies": { - "p-try": "^2.0.0" + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" }, "engines": { - "node": ">=6" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/cosmiconfig-typescript-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz", + "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==", "dev": true, "dependencies": { - "p-limit": "^2.2.0" + "jiti": "^1.19.1" }, "engines": { - "node": ">=8" + "node": ">=v16" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=8.2", + "typescript": ">=4" } }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", "dev": true, "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, - "bin": { - "semver": "bin/semver" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", "dev": true, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "ms": "2.1.2" }, "engines": { - "node": ">= 6" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "mimic-response": "^3.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0.0" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "dev": true, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/resolve-global": { + "node_modules/delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "dependencies": { - "global-dirs": "^0.1.1" + "path-type": "^4.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" + "is-obj": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "dev": true, + "optional": true, "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" + "iconv-lite": "^0.6.2" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true }, - "node_modules/spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "is-arrayish": "^0.2.1" } }, - "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", - "dev": true - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "engines": { + "node": ">=6" } }, - "node_modules/spdx-license-ids": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz", - "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==", - "dev": true - }, - "node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "dev": true, - "dependencies": { - "readable-stream": "^3.0.0" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=0.8.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "dependencies": { - "ansi-regex": "^5.0.1" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" }, "engines": { - "node": ">=8" + "node": ">=8.6.0" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, - "engines": { - "node": ">=6" + "dependencies": { + "reusify": "^1.0.4" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "dependencies": { - "min-indent": "^1.0.0" + "to-regex-range": "^5.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-extensions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", - "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", "dev": true, "engines": { - "node": ">=0.10" + "node": ">= 14.17" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "node_modules/fp-and-or": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fp-and-or/-/fp-and-or-0.1.4.tgz", + "integrity": "sha512-+yRYRhpnFPWXSly/6V4Lw9IfOV26uu30kynGJ03PW+MnjOEQe45RZ141QcS0aJehYBYA50GfCDnsRbFJdhssRw==", + "dev": true, + "engines": { + "node": ">=10" + } }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "dev": true, "dependencies": { - "readable-stream": "3" + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "node_modules/fs-minipass/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "dev": true, "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "engines": { "node": ">=10" @@ -2042,1767 +2055,3185 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", "dev": true, + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "git-raw-commits": "cli.mjs" }, "engines": { - "node": ">=4.2.0" + "node": ">=16" } }, - "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "node_modules/glob": { + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { - "punycode": "^2.1.0" + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "node_modules/glob/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "dev": true, "dependencies": { - "isexe": "^2.0.0" + "ini": "4.1.1" }, - "bin": { - "node-which": "bin/node-which" + "engines": { + "node": ">=18" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, "engines": { - "node": ">= 8" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", "dev": true, "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ini": "2.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "node_modules/global-dirs/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", "dev": true, "engines": { "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.6.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", - "integrity": "sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==", + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/got": { + "version": "12.6.1", + "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", + "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", "dev": true, + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, "engines": { - "node": ">=12" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true + }, + "node_modules/has-yarn": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", + "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", "dev": true, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", - "dev": true, - "requires": { - "@babel/highlight": "^7.18.6" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true - }, - "@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } }, - "@commitlint/cli": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-17.4.2.tgz", - "integrity": "sha512-0rPGJ2O1owhpxMIXL9YJ2CgPkdrFLKZElIZHXDN8L8+qWK1DGH7Q7IelBT1pchXTYTuDlqkOTdh//aTvT3bSUA==", - "dev": true, - "requires": { - "@commitlint/format": "^17.4.0", - "@commitlint/lint": "^17.4.2", - "@commitlint/load": "^17.4.2", - "@commitlint/read": "^17.4.2", - "@commitlint/types": "^17.4.0", - "execa": "^5.0.0", - "lodash.isfunction": "^3.0.9", - "resolve-from": "5.0.0", - "resolve-global": "1.0.0", - "yargs": "^17.0.0" - } - }, - "@commitlint/config-conventional": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-17.4.2.tgz", - "integrity": "sha512-JVo1moSj5eDMoql159q8zKCU8lkOhQ+b23Vl3LVVrS6PXDLQIELnJ34ChQmFVbBdSSRNAbbXnRDhosFU+wnuHw==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, - "requires": { - "conventional-changelog-conventionalcommits": "^5.0.0" + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "@commitlint/config-validator": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-17.4.0.tgz", - "integrity": "sha512-Sa/+8KNpDXz4zT4bVbz2fpFjvgkPO6u2V2fP4TKgt6FjmOw2z3eEX859vtfeaTav/ukBw0/0jr+5ZTZp9zCBhA==", + "node_modules/hosted-git-info": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-5.2.1.tgz", + "integrity": "sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw==", "dev": true, - "requires": { - "@commitlint/types": "^17.4.0", - "ajv": "^8.11.0" + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "@commitlint/ensure": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-17.4.0.tgz", - "integrity": "sha512-7oAxt25je0jeQ/E0O/M8L3ADb1Cvweu/5lc/kYF8g/kXatI0wxGE5La52onnAUAWeWlsuvBNar15WcrmDmr5Mw==", + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, - "requires": { - "@commitlint/types": "^17.4.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "engines": { + "node": ">=12" } }, - "@commitlint/execute-rule": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-17.4.0.tgz", - "integrity": "sha512-LIgYXuCSO5Gvtc0t9bebAMSwd68ewzmqLypqI2Kke1rqOqqDbMpYcYfoPfFlv9eyLIh4jocHWwCK5FS7z9icUA==", + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, - "@commitlint/format": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-17.4.0.tgz", - "integrity": "sha512-Z2bWAU5+f1YZh9W76c84J8iLIWIvvm+mzqogTz0Nsc1x6EHW0Z2gI38g5HAjB0r0I3ZjR15IDEJKhsxyblcyhA==", + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "dev": true, - "requires": { - "@commitlint/types": "^17.4.0", - "chalk": "^4.1.0" + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, - "@commitlint/is-ignored": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-17.4.2.tgz", - "integrity": "sha512-1b2Y2qJ6n7bHG9K6h8S4lBGUl6kc7mMhJN9gy1SQfUZqe92ToDjUTtgNWb6LbzR1X8Cq4SEus4VU8Z/riEa94Q==", + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", "dev": true, - "requires": { - "@commitlint/types": "^17.4.0", - "semver": "7.3.8" + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" } }, - "@commitlint/lint": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-17.4.2.tgz", - "integrity": "sha512-HcymabrdBhsDMNzIv146+ZPNBPBK5gMNsVH+el2lCagnYgCi/4ixrHooeVyS64Fgce2K26+MC7OQ4vVH8wQWVw==", + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, - "requires": { - "@commitlint/is-ignored": "^17.4.2", - "@commitlint/parse": "^17.4.2", - "@commitlint/rules": "^17.4.2", - "@commitlint/types": "^17.4.0" + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, - "@commitlint/load": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-17.4.2.tgz", - "integrity": "sha512-Si++F85rJ9t4hw6JcOw1i2h0fdpdFQt0YKwjuK4bk9KhFjyFkRxvR3SB2dPaMs+EwWlDrDBGL+ygip1QD6gmPw==", + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, - "requires": { - "@commitlint/config-validator": "^17.4.0", - "@commitlint/execute-rule": "^17.4.0", - "@commitlint/resolve-extends": "^17.4.0", - "@commitlint/types": "^17.4.0", - "@types/node": "*", - "chalk": "^4.1.0", - "cosmiconfig": "^8.0.0", - "cosmiconfig-typescript-loader": "^4.0.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0", - "resolve-from": "^5.0.0", - "ts-node": "^10.8.1", - "typescript": "^4.6.4" + "engines": { + "node": ">=16.17.0" } }, - "@commitlint/message": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-17.4.2.tgz", - "integrity": "sha512-3XMNbzB+3bhKA1hSAWPCQA3lNxR4zaeQAQcHj0Hx5sVdO6ryXtgUBGGv+1ZCLMgAPRixuc6en+iNAzZ4NzAa8Q==", - "dev": true - }, - "@commitlint/parse": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-17.4.2.tgz", - "integrity": "sha512-DK4EwqhxfXpyCA+UH8TBRIAXAfmmX4q9QRBz/2h9F9sI91yt6mltTrL6TKURMcjUVmgaB80wgS9QybNIyVBIJA==", + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, - "requires": { - "@commitlint/types": "^17.4.0", - "conventional-changelog-angular": "^5.0.11", - "conventional-commits-parser": "^3.2.2" + "dependencies": { + "ms": "^2.0.0" } }, - "@commitlint/read": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-17.4.2.tgz", - "integrity": "sha512-hasYOdbhEg+W4hi0InmXHxtD/1favB4WdwyFxs1eOy/DvMw6+2IZBmATgGOlqhahsypk4kChhxjAFJAZ2F+JBg==", + "node_modules/husky": { + "version": "9.0.11", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", + "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==", "dev": true, - "requires": { - "@commitlint/top-level": "^17.4.0", - "@commitlint/types": "^17.4.0", - "fs-extra": "^11.0.0", - "git-raw-commits": "^2.0.0", - "minimist": "^1.2.6" + "bin": { + "husky": "bin.mjs" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" } }, - "@commitlint/resolve-extends": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-17.4.0.tgz", - "integrity": "sha512-3JsmwkrCzoK8sO22AzLBvNEvC1Pmdn/65RKXzEtQMy6oYMl0Snrq97a5bQQEFETF0VsvbtUuKttLqqgn99OXRQ==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, - "requires": { - "@commitlint/config-validator": "^17.4.0", - "@commitlint/types": "^17.4.0", - "import-fresh": "^3.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0" + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "@commitlint/rules": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-17.4.2.tgz", - "integrity": "sha512-OGrPsMb9Fx3/bZ64/EzJehY9YDSGWzp81Pj+zJiY+r/NSgJI3nUYdlS37jykNIugzazdEXfMtQ10kmA+Kx2pZQ==", + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, - "requires": { - "@commitlint/ensure": "^17.4.0", - "@commitlint/message": "^17.4.2", - "@commitlint/to-lines": "^17.4.0", - "@commitlint/types": "^17.4.0", - "execa": "^5.0.0" + "engines": { + "node": ">= 4" } }, - "@commitlint/to-lines": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-17.4.0.tgz", - "integrity": "sha512-LcIy/6ZZolsfwDUWfN1mJ+co09soSuNASfKEU5sCmgFCvX5iHwRYLiIuoqXzOVDYOy7E7IcHilr/KS0e5T+0Hg==", - "dev": true - }, - "@commitlint/top-level": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-17.4.0.tgz", - "integrity": "sha512-/1loE/g+dTTQgHnjoCy0AexKAEFyHsR2zRB4NWrZ6lZSMIxAhBJnmCqwao7b4H8888PsfoTBCLBYIw8vGnej8g==", + "node_modules/ignore-walk": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz", + "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==", "dev": true, - "requires": { - "find-up": "^5.0.0" + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "@commitlint/types": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-17.4.0.tgz", - "integrity": "sha512-2NjAnq5IcxY9kXtUeO2Ac0aPpvkuOmwbH/BxIm36XXK5LtWFObWJWjXOA+kcaABMrthjWu6la+FUpyYFMHRvbA==", + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, - "requires": { - "chalk": "^4.1.0" + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" + "engines": { + "node": ">=4" } }, - "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "engines": { + "node": ">=8" } }, - "@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true - }, - "@types/minimist": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", - "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", - "dev": true - }, - "@types/node": { - "version": "18.13.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", - "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==", - "dev": true - }, - "@types/normalize-package-data": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", - "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", - "dev": true - }, - "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true - }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true - }, - "ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/import-meta-resolve": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", + "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, - "requires": { - "color-convert": "^2.0.1" + "engines": { + "node": ">=8" } }, - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", "dev": true }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } }, - "array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true + "node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", "dev": true, - "requires": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "engines": { + "node": ">=0.10.0" } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "requires": { - "color-name": "~1.1.4" + "engines": { + "node": ">=8" } }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "requires": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" } }, - "conventional-changelog-angular": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", - "integrity": "sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==", + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", "dev": true, - "requires": { - "compare-func": "^2.0.0", - "q": "^1.5.1" + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "conventional-changelog-conventionalcommits": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz", - "integrity": "sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==", + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-npm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", + "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", "dev": true, - "requires": { - "compare-func": "^2.0.0", - "lodash": "^4.17.15", - "q": "^1.5.1" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "conventional-commits-parser": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.4.tgz", - "integrity": "sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "requires": { - "is-text-path": "^1.0.1", - "JSONStream": "^1.0.4", - "lodash": "^4.17.15", - "meow": "^8.0.0", - "split2": "^3.0.0", - "through2": "^4.0.0" + "engines": { + "node": ">=0.12.0" } }, - "cosmiconfig": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.0.0.tgz", - "integrity": "sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ==", + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, - "requires": { - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0" + "engines": { + "node": ">=8" } }, - "cosmiconfig-typescript-loader": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", - "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "requires": {} - }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "engines": { + "node": ">=8" + } }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "dargs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", - "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", - "dev": true - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true - }, - "decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", "dev": true, - "requires": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, "dependencies": { - "map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true - } + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "node_modules/is-yarn-global": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", + "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", "dev": true, - "requires": { - "is-obj": "^2.0.0" + "engines": { + "node": ">=12" } }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", "dev": true, - "requires": { - "is-arrayish": "^0.2.1" + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "bin": { + "jiti": "bin/jiti.js" } }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, - "fs-extra": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", - "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "dev": true }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "git-raw-commits": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", - "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", + "node_modules/json-parse-helpfulerror": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz", + "integrity": "sha512-XgP0FGR77+QhUxjXkwOMkC94k3WtqEBfcnjWqhRd82qTat4SWKRE+9kUnynz/shm3I4ea2+qISvTIeGTNU7kJg==", "dev": true, - "requires": { - "dargs": "^7.0.0", - "lodash": "^4.17.15", - "meow": "^8.0.0", - "split2": "^3.0.0", - "through2": "^4.0.0" + "dependencies": { + "jju": "^1.1.0" } }, - "global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "requires": { - "ini": "^1.3.4" + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, - "graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "node_modules/jsonlines": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", + "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", "dev": true }, - "hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", - "dev": true + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", "dev": true, - "requires": { - "function-bind": "^1.1.1" + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" } }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } }, - "hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, - "requires": { - "lru-cache": "^6.0.0" + "engines": { + "node": ">=6" } }, - "human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true + "node_modules/latest-version": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", + "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", + "dev": true, + "dependencies": { + "package-json": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "dependencies": { - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - } + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true - }, - "is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true - }, - "is-text-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", - "dev": true, - "requires": { - "text-extensions": "^1.0.0" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true - }, - "JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "requires": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - } - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true - }, - "lodash.isfunction": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", - "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", - "dev": true - }, - "lodash.isplainobject": { + "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true }, - "lodash.kebabcase": { + "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", "dev": true }, - "lodash.merge": { + "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "lodash.mergewith": { + "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, - "lodash.snakecase": { + "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "dev": true }, - "lodash.startcase": { + "node_modules/lodash.startcase": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true }, - "lodash.uniq": { + "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, - "lodash.upperfirst": { + "node_modules/lodash.upperfirst": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", "dev": true }, - "lru-cache": { + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, - "requires": { + "dependencies": { "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "node_modules/make-fetch-happen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", + "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "dev": true - }, - "meow": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", - "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", - "dev": true, - "requires": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - } - }, - "merge-stream": { + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true - }, - "minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "dev": true - }, - "minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "requires": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" + "engines": { + "node": ">= 8" } }, - "normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, - "requires": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" } }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, - "requires": { - "path-key": "^3.0.0" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", "dev": true, - "requires": { - "mimic-fn": "^2.1.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, - "requires": { - "yocto-queue": "^0.1.0" + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, - "requires": { - "p-limit": "^3.0.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "dev": true, - "requires": { - "callsites": "^3.0.0" + "engines": { + "node": ">=8" } }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" } }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true - }, - "q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "dev": true - }, - "quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", - "dev": true - }, - "read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, - "requires": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, "dependencies": { - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - }, - "type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true - } + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "requires": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - } + "node_modules/minipass-fetch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", + "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" } }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "redent": { + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-json-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", + "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", + "dev": true, + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/minipass-json-stream/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/node-gyp/node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/node-gyp/node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/node-gyp/node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-gyp/node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/node-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz", + "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==", + "dev": true, + "dependencies": { + "hosted-git-info": "^6.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", + "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", + "dev": true, + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-bundled": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", + "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "dev": true, + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-check-updates": { + "version": "16.14.18", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.18.tgz", + "integrity": "sha512-9iaRe9ohx9ykdbLjPRIYcq1A0RkrPYUx9HmQK1JIXhfxtJCNE/+497H9Z4PGH6GWRALbz5KF+1iZoySK2uSEpQ==", + "dev": true, + "dependencies": { + "@types/semver-utils": "^1.1.1", + "chalk": "^5.3.0", + "cli-table3": "^0.6.3", + "commander": "^10.0.1", + "fast-memoize": "^2.5.2", + "find-up": "5.0.0", + "fp-and-or": "^0.1.4", + "get-stdin": "^8.0.0", + "globby": "^11.0.4", + "hosted-git-info": "^5.1.0", + "ini": "^4.1.1", + "js-yaml": "^4.1.0", + "json-parse-helpfulerror": "^1.0.3", + "jsonlines": "^0.1.1", + "lodash": "^4.17.21", + "make-fetch-happen": "^11.1.1", + "minimatch": "^9.0.3", + "p-map": "^4.0.0", + "pacote": "15.2.0", + "parse-github-url": "^1.0.2", + "progress": "^2.0.3", + "prompts-ncu": "^3.0.0", + "rc-config-loader": "^4.1.3", + "remote-git-tags": "^3.0.0", + "rimraf": "^5.0.5", + "semver": "^7.5.4", + "semver-utils": "^1.1.4", + "source-map-support": "^0.5.21", + "spawn-please": "^2.0.2", + "strip-ansi": "^7.1.0", + "strip-json-comments": "^5.0.1", + "untildify": "^4.0.0", + "update-notifier": "^6.0.2" + }, + "bin": { + "ncu": "build/src/bin/cli.js", + "npm-check-updates": "build/src/bin/cli.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/npm-check-updates/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm-check-updates/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm-install-checks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", + "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz", + "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/hosted-git-info": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", + "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", + "dev": true, + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm-packlist": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-7.0.4.tgz", + "integrity": "sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==", + "dev": true, + "dependencies": { + "ignore-walk": "^6.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.2.tgz", + "integrity": "sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==", + "dev": true, + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^10.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-14.0.5.tgz", + "integrity": "sha512-kIDMIo4aBm6xg7jOttupWZamsZRkAqMqwqqbVXnUqstY5+tapvv6bkH/qMR76jdgV+YljEUCyWx3hRYMrJiAgA==", + "dev": true, + "dependencies": { + "make-fetch-happen": "^11.0.0", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^10.0.0", + "proc-log": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "dev": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "dev": true, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", + "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", + "dev": true, + "dependencies": { + "got": "^12.1.0", + "registry-auth-token": "^5.0.1", + "registry-url": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz", + "integrity": "sha512-rJVZeIwHTUta23sIZgEIM62WYwbmGbThdbnkt81ravBplQv+HjyroqnLRNH2+sLJHcGZmLRmhPwACqhfTcOmnA==", + "dev": true, + "dependencies": { + "@npmcli/git": "^4.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/promise-spawn": "^6.0.1", + "@npmcli/run-script": "^6.0.0", + "cacache": "^17.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^5.0.0", + "npm-package-arg": "^10.0.0", + "npm-packlist": "^7.0.0", + "npm-pick-manifest": "^8.0.0", + "npm-registry-fetch": "^14.0.0", + "proc-log": "^3.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^6.0.0", + "read-package-json-fast": "^3.0.0", + "sigstore": "^1.3.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "lib/bin.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-github-url": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", + "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==", + "dev": true, + "bin": { + "parse-github-url": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", + "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts-ncu": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prompts-ncu/-/prompts-ncu-3.0.0.tgz", + "integrity": "sha512-qyz9UxZ5MlPKWVhWrCmSZ1ahm2GVYdjLb8og2sg0IPth1KRuhcggHGuijz0e41dkx35p1t1q3GRISGH7QGALFA==", + "dev": true, + "dependencies": { + "kleur": "^4.0.1", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", + "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "dev": true, + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz", + "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "js-yaml": "^4.1.0", + "json5": "^2.2.2", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-package-json": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.4.tgz", + "integrity": "sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==", + "dev": true, + "dependencies": { + "glob": "^10.2.2", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", + "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", + "dev": true, + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/registry-auth-token": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", + "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", + "dev": true, + "dependencies": { + "@pnpm/npm-conf": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/remote-git-tags": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remote-git-tags/-/remote-git-tags-3.0.0.tgz", + "integrity": "sha512-C9hAO4eoEsX+OXA4rla66pXZQ+TLQ8T9dttgQj18yuKlPMTVkIkdYXvlMC55IuUsIkV6DpmQYi10JKFLaU+l7w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "optional": true + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-utils": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/semver-utils/-/semver-utils-1.1.4.tgz", + "integrity": "sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA==", + "dev": true + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "requires": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "engines": { + "node": ">=8" } }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-1.9.0.tgz", + "integrity": "sha512-0Zjz0oe37d08VeOtBIuB6cRriqXse2e8w+7yIy2XSXjshRKxbc2KkhXjL229jXSxEm7UbcjS76wcJDGQddVI9A==", + "dev": true, + "dependencies": { + "@sigstore/bundle": "^1.1.0", + "@sigstore/protobuf-specs": "^0.2.0", + "@sigstore/sign": "^1.0.0", + "@sigstore/tuf": "^1.0.3", + "make-fetch-happen": "^11.0.1" + }, + "bin": { + "sigstore": "bin/sigstore.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "dev": true }, - "require-from-string": { + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", + "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "dev": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawn-please": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.2.tgz", + "integrity": "sha512-KM8coezO6ISQ89c1BzyWNtcn2V2kAVtwIXd3cN/V5a0xPYc1F/vydrRc01wsKFEQ/p+V1a4sw4z2yMITIXrgGw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, - "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", "dev": true }, - "resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, - "requires": { - "global-dirs": "^0.1.1" + "engines": { + "node": ">= 10.x" } }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "node_modules/ssri": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", + "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", "dev": true, - "requires": { - "lru-cache": "^6.0.0" + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/ssri/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, - "requires": { - "shebang-regex": "^3.0.0" + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "shebang-regex": { + "node_modules/strip-final-newline": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "node_modules/strip-json-comments": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", + "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } }, - "spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" } }, - "spdx-license-ids": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz", - "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==", - "dev": true - }, - "split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "node_modules/tuf-js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-1.1.7.tgz", + "integrity": "sha512-i3P9Kgw3ytjELUfpuKVDNBJvk4u5bXL6gskv572mcevPbSKCV3zt3djhmlEQ65yERjIbOSncy7U4cQJaB1CBCg==", "dev": true, - "requires": { - "readable-stream": "^3.0.0" + "dependencies": { + "@tufjs/models": "1.0.4", + "debug": "^4.3.4", + "make-fetch-happen": "^11.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, - "requires": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "dependencies": { + "is-typedarray": "^1.0.0" } }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "dev": true, - "requires": { - "ansi-regex": "^5.0.1" + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, - "strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, - "requires": { - "min-indent": "^1.0.0" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", "dev": true, - "requires": { - "has-flag": "^4.0.0" + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "text-extensions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", - "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", - "dev": true - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", "dev": true, - "requires": { - "readable-stream": "3" + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "dev": true - }, - "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "dev": true - }, - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "engines": { + "node": ">=8" + } }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true + "node_modules/update-notifier": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", + "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", + "dev": true, + "dependencies": { + "boxen": "^7.0.0", + "chalk": "^5.0.1", + "configstore": "^6.0.0", + "has-yarn": "^3.0.0", + "import-lazy": "^4.0.0", + "is-ci": "^3.0.1", + "is-installed-globally": "^0.4.0", + "is-npm": "^6.0.0", + "is-yarn-global": "^0.4.0", + "latest-version": "^7.0.0", + "pupa": "^3.1.0", + "semver": "^7.3.7", + "semver-diff": "^4.0.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } }, - "uri-js": { + "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "requires": { + "dependencies": { "punycode": "^2.1.0" } }, - "util-deprecate": { + "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, - "validate-npm-package-license": { + "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "requires": { + "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, - "which": { + "node_modules/validate-npm-package-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", + "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", + "dev": true, + "dependencies": { + "builtins": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "requires": { + "dependencies": { "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "wrap-ansi": { + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "requires": { + "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "y18n": { + "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + } }, - "yallist": { + "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "yargs": { - "version": "17.6.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", - "integrity": "sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==", + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, - "requires": { + "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", @@ -3811,32 +5242,30 @@ "y18n": "^5.0.5", "yargs-parser": "^21.1.1" }, - "dependencies": { - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - } + "engines": { + "node": ">=12" } }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } }, - "yocto-queue": { + "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index a861991f..69b35213 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,25 @@ { - "name": "ecommerce-microservices", + "name": "food-delivery-microservices", "version": "1.0.0", - "description": "ecommerce-microservices", + "description": "food-delivery-microservices", "scripts": { - "prepare": "husky install && dotnet tool restore && curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2019 -l ~/vsdbg" + "prepare": "husky install && dotnet tool restore", + "install-dev-cert-bash": "curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2019 -l ~/vsdbg" }, "repository": { "type": "git", - "url": "git+https://github.com/mehdihadeli/ecommerce-microservices.git" + "url": "git+https://github.com/mehdihadeli/food-delivery-microservices.git" }, "author": "", "license": "MIT", "bugs": { - "url": "https://github.com/mehdihadeli/ecommerce-microservices/issues" + "url": "https://github.com/mehdihadeli/food-delivery-microservices/issues" }, - "homepage": "https://github.com/mehdihadeli/ecommerce-microservices#readme", + "homepage": "https://github.com/mehdihadeli/food-delivery-microservices#readme", "devDependencies": { - "@commitlint/cli": "^17.4.2", - "@commitlint/config-conventional": "^17.4.2", - "husky": "^8.0.3" + "@commitlint/cli": "^19.2.1", + "@commitlint/config-conventional": "^19.1.0", + "husky": "^9.0.11", + "npm-check-updates": "^16.14.18" } } diff --git a/pm2.json b/pm2.json new file mode 100644 index 00000000..4592e599 --- /dev/null +++ b/pm2.json @@ -0,0 +1,11 @@ +{ + "apps": [ + { + "name": "api-gateway", + "script": "dotnet", + "args" : "run", + "cwd": "/src/ApiGateway/FoodDelivery.ApiGateway/", + "autorestart": false + } + ] + } diff --git a/pm2.yaml b/pm2.yaml new file mode 100644 index 00000000..05c985ce --- /dev/null +++ b/pm2.yaml @@ -0,0 +1,26 @@ +# https://stackoverflow.com/a/42619477/581476 +# https://pm2.keymetrics.io/docs/usage/application-declaration/ +apps: + - name: api-gateway + cwd: src\ApiGateway\FoodDelivery.ApiGateway + script: dotnet + args: run + autorestart: false + + - name: catalogs-service + cwd: src\Services\Catalogs\FoodDelivery.Services.Catalogs.Api + script: dotnet + args: run + autorestart: false + + - name: customers-service + cwd: src\Services\Customers\FoodDelivery.Services.Customers.Api + script: dotnet + args: run + autorestart: false + + - name: Identity-service + cwd: src\Services\Identity\FoodDelivery.Services.Identity.Api + script: dotnet + args: run + autorestart: false diff --git a/readme.md b/readme.md index 912b6321..9baf3a37 100644 --- a/readme.md +++ b/readme.md @@ -7,16 +7,18 @@ [![Gitpod](https://img.shields.io/static/v1?style=for-the-badge&message=Open%20in%20Gitpod&color=222222&logo=Gitpod&logoColor=FFAE33&label=)](https://gitpod.io/https://github.com/mehdihadeli/food-delivery-microservices) [![Codespaces](https://img.shields.io/static/v1?style=for-the-badge&message=Open%20in%20GitHub%20Codespaces&color=181717&logo=GitHub&logoColor=FFFFFF&label=)](https://mehdihadeli-humble-space-couscous-5x5pqwwjx5c7664.github.dev) -> `Food Delivery Microservices` is a practical and imaginary food delivery microservices, built with .Net Core and different software architecture and technologies like **Microservices Architecture**, **Vertical Slice Architecture** , **CQRS Pattern**, **Domain Driven Design (DDD)**, **Event Driven Architecture**. For communication between independent services, we use asynchronous messaging using rabbitmq on top of [MassTransit](https://github.com/MassTransit/MassTransit) library, and sometimes we use synchronous communication for real-time communications using REST and gRPC calls. + -💡 This application is not business-oriented and my focus is mostly on the technical part, I just want to implement a sample using different technologies, software architecture design, principles, and all the things we need for creating a microservices app. +> `Food Delivery Microservices` is a fictional food delivery microservices, built with .Net Core and different software architecture and technologies like **Microservices Architecture**, **Vertical Slice Architecture** , **CQRS Pattern**, **Domain Driven Design (DDD)**, **Event Driven Architecture**. For communication between independent services, we use asynchronous messaging with using rabbitmq on top of [MassTransit](https://github.com/MassTransit/MassTransit) library, and sometimes we use synchronous communication for real-time communications with using REST and gRPC calls. + +💡 This application is not business oriented and my focus is mostly on technical part, I just want to implement a sample with using different technologies, software architecture design, principles and all the thing we need for creating a microservices app. > **Warning** -> This project is in progress. I add new features over time. You can check the [Release Notes](https://github.com/mehdihadeli/food-delivery-microservices/releases). +> This project is in progress. I add new features over the time. You can check the [Release Notes](https://github.com/mehdihadeli/food-delivery-microservices/releases). -🎯 This Application ported to `modular monolith` approach in [food-delivery-modular-monolith](https://github.com/mehdihadeli/food-delivery-modular-monolith) repository, we can choose the best-fit architecture for our projects based on production needs. +🎯 This Application ported to `modular monolith` approach in [food-delivery-modular-monolith](https://github.com/mehdihadeli/food-delivery-modular-monolith) repository, we can choose best fit architecture for our projects based on production needs. -Other versions of this project are available in these repositories, We can choose best-fit architecture for our projects based on production needs: +Other versions of this project are available in these repositories, We can choose best fit architecture for our projects based on production needs: - [https://github.com/mehdihadeli/food-delivery-modular-monolith](https://github.com/mehdihadeli/food-delivery-modular-monolith) - [https://github.com/mehdihadeli/go-food-delivery-microservices](https://github.com/mehdihadeli/go-food-delivery-microservices) @@ -88,7 +90,7 @@ Thanks a bunch for supporting me! ## Technologies - Libraries -- ✔️ **[`.NET 7`](https://dotnet.microsoft.com/download)** - .NET Framework and .NET Core, including ASP.NET and ASP.NET Core +- ✔️ **[`.NET 8`](https://dotnet.microsoft.com/download)** - .NET Framework and .NET Core, including ASP.NET and ASP.NET Core - ✔️ **[`MassTransit`](https://github.com/MassTransit/MassTransit)** - Distributed Application Framework for .NET - ✔️ **[`StackExchange.Redis`](https://github.com/StackExchange/StackExchange.Redis)** - General purpose redis client - ✔️ **[`Npgsql Entity Framework Core Provider`](https://www.npgsql.org/efcore/)** - Npgsql has an Entity Framework (EF) Core provider. It behaves like other EF Core providers (e.g. SQL Server), so the general EF Core docs apply here as well @@ -152,7 +154,11 @@ npm init npm install husky --save-dev ``` -3. Add `prepare` and `install-dev-cert-bash` command for installing and activating `husky hooks` in the package.json file: +3. Add `prepare` and `install-dev-cert-bash` commands for installing and activating `husky hooks` and [`dotnet tools`](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) in the package.json file: + +- Actually [prepare](https://docs.npmjs.com/cli/v10/using-npm/scripts#life-cycle-scripts) is a special `life cycle scripts` that runs automatically on `local npm install` without any arguments. +- The [scripts](https://docs.npmjs.com/cli/v10/using-npm/scripts) property of your package.json file supports a number of built-in scripts and their preset life cycle events as well as arbitrary scripts. These all can be executed by running `npm run-script ` or `npm run ` for short. +- For working `dotnet tools restore` commands to install and update local packages we should have a valid `nuget.config` file in the root of our project. we can create a `nuget.config` file with using `dotnet new nugetconfig` command. ```bash npm pkg set scripts.prepare="husky install && dotnet tool restore" @@ -160,6 +166,13 @@ npm pkg set scripts.prepare="husky install && dotnet tool restore" npm pkg set scripts.install-dev-cert-bash="curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2019 -l ~/vsdbg" ``` +``` json +"scripts": { +"prepare": "husky install && dotnet tool restore", +"install-dev-cert-bash": "curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2019 -l ~/vsdbg" +} +``` + 4. Install CommitLint: ```bash @@ -226,7 +239,7 @@ dotnet tool install csharpier dotnet tool install dotnet-format ``` -4. Add `prepare` command for installing and activating `husky hooks` and `restoring` our installed [dotnet tools](.config/dotnet-tools.json) in the previous step to the [package.json](package.json) file: +4. Add `prepare` command for installing and activating `husky hooks` and `restoring` our [dotnet tools](.config/dotnet-tools.json) in the previous step to the [package.json](package.json) file: ```bash npm pkg set scripts.prepare="husky install && dotnet tool restore" @@ -273,7 +286,7 @@ The bellow architecture shows that there is one public API (API Gateway) which i Microservices are event based which means they can publish and/or subscribe to any events occurring in the setup. By using this approach for communicating between services, each microservice does not need to know about the other services or handle errors occurred in other microservices. -In this architecture we use [CQRS Pattern](https://www.eventstore.com/cqrs-pattern) for separating read and write model beside of other [CQRS Advantages](https://youtu.be/dK4Yb6-LxAk?t=1029). Here for now I don't use [Event Sourcing](https://www.eventstore.com/blog/event-sourcing-and-cqrs) for simplicity but I will use it in future for syncing read and write side with sending streams and using [Projection Feature](https://event-driven.io/en/how_to_do_events_projections_with_entity_framework/) for some subscribers to syncing their data through sent streams and creating our [Custom Read Models](https://codeopinion.com/projections-in-event-sourcing-build-any-model-you-want/) in subscribers side. +In this architecture we use [CQRS Pattern](https://www.eventecommerce.com/cqrs-pattern) for separating read and write model beside of other [CQRS Advantages](https://youtu.be/dK4Yb6-LxAk?t=1029). Here for now I don't use [Event Sourcing](https://www.eventecommerce.com/blog/event-sourcing-and-cqrs) for simplicity but I will use it in future for syncing read and write side with sending streams and using [Projection Feature](https://event-driven.io/en/how_to_do_events_projections_with_entity_framework/) for some subscribers to syncing their data through sent streams and creating our [Custom Read Models](https://codeopinion.com/projections-in-event-sourcing-build-any-model-you-want/) in subscribers side. Here I have a write model that uses a postgres database for handling better `Consistency` and `ACID Transaction` guaranty. beside o this write side I use a read side model that uses MongoDB for better performance of our read side without any joins with suing some nested document in our document also better scalability with some good scaling features of MongoDB. @@ -317,7 +330,7 @@ In this project I used [vertical slice architecture](https://jimmybogard.com/ver ![](./assets/vsa2.png) -Also here I used [CQRS](https://www.eventstore.com/cqrs-pattern) for decompose my features to very small parts that makes our application: +Also here I used [CQRS](https://www.eventecommerce.com/cqrs-pattern) for decompose my features to very small parts that makes our application: - maximize performance, scalability and simplicity. - adding new feature to this mechanism is very easy without any breaking change in other part of our codes. New features only add code, we're not changing shared code and worrying about side effects. diff --git a/scripts/docker/base-run.sh b/scripts/docker/base-run.sh index 20e495c9..cbede8f8 100644 --- a/scripts/docker/base-run.sh +++ b/scripts/docker/base-run.sh @@ -20,5 +20,5 @@ source_path=$6 #because we use `base` image directly for running app, and we don't have any source code and nuggets and entrypoint (so our container not be launch) in base layer we should map source code and vsdbg as a volume or using in launch time in launch.json on base layer. In launch.json app will run with `pipeTransport` and type `coreclr` and after connecting to base layer container with running vsdb on the container and then coreclr will launch specified `program` with `dotnet run` on the container and pass `args` to `dotnet run` as launch program (nugget path, ... as --additionalProbingPath because our dll is in debug build and need to resolve all nugget dependecies that doesn't exist in this build). if [ -z "$(docker ps -q -f name=${container_name})" ]; then - docker run --rm -d -it --publish $http_port:80 --publish $https_port:443 --publish-all --name $container_name --env-file $env_path --network=ecommerce --mount type=bind,src=$source_path,dst=/app --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name + docker run --rm -d -it --publish $http_port:80 --publish $https_port:443 --publish-all --name $container_name --env-file $env_path --network=food-delivery --mount type=bind,src=$source_path,dst=/app --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name fi diff --git a/scripts/docker/debug-run.sh b/scripts/docker/debug-run.sh index 851c879a..8b259436 100644 --- a/scripts/docker/debug-run.sh +++ b/scripts/docker/debug-run.sh @@ -11,6 +11,7 @@ env_path=$5 #https://www.powercms.in/article/how-automatically-delete-docker-container-after-running-it #https://www.richard-banks.org/2018/07/debugging-core-in-docker.html #https://docs.docker.com/engine/reference/commandline/run/#mount +#https://docs.docker.com/engine/reference/commandline/run/#env #https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run #https://codewithyury.com/docker-run-vs-cmd-vs-entrypoint/ #https://devopscube.com/keep-docker-container-running/ @@ -20,9 +21,9 @@ env_path=$5 #https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user # here if we don't use detached mode this task block process for inreactive mode and prevent to use launch debuger in laucnch.json #--rm doesn't work in detached mode -#here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `container_name` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` +#here we use full build image for debugging but we change dcoker file `entrypoint` durring `docker run` for for preventing launch app in docker container but with using new entrypoint our stage will run on app working directory and then in our launch.json we launch our app inner container with connecting to `container_name` container with `pipeTransport` and `vsdbg` (internaly use dcoker exec and run vsdb on container) and then with using coreclr type and prgram to run, it will run this program with `dotnet run` by coreclr and passed `args` #mappings increase the size of docker image so we use it just in debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size if [ -z "$(docker ps -q -f name=${container_name})" ]; then - docker run -it --rm -d --publish $http_port:80 --publish $https_port:443 --publish-all --name $container_name --entrypoint 'bash' --env-file $env_path --network=ecommerce --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name + docker run -it --rm -d --publish $http_port:80 --publish $https_port:443 --publish-all --name $container_name --entrypoint 'bash' --env-file $env_path --network=food-delivery --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name fi diff --git a/scripts/docker/dev-run.sh b/scripts/docker/dev-run.sh index 5e412385..9bd528cd 100644 --- a/scripts/docker/dev-run.sh +++ b/scripts/docker/dev-run.sh @@ -11,6 +11,7 @@ env_path=$5 #https://www.powercms.in/article/how-automatically-delete-docker-container-after-running-it #https://www.richard-banks.org/2018/07/debugging-core-in-docker.html #https://docs.docker.com/engine/reference/commandline/run/#mount +#https://docs.docker.com/engine/reference/commandline/run/#env #https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec #https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user # here if we don't use detached mode this task block process for inreactive mode and prevent to use launch debuger in laucnch.json @@ -18,5 +19,5 @@ env_path=$5 #mappings increase the size of docker image so we use it just in debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size if [ -z "$(docker ps -q -f name=${container_name})" ]; then - docker run -it --rm --publish $http_port:80 --publish $https_port:443 --publish-all --env-file $env_path --network=ecommerce --name $container_name --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name + docker run -it --rm --publish $http_port:80 --publish $https_port:443 --publish-all --env-file $env_path --network=food-delivery --name $container_name --mount type=bind,src=${HOME}/vsdbg,dst=/vsdbg --mount type=bind,source=${HOME}/.nuget/packages,destination=/root/.nuget/packages,readonly --mount type=bind,source=${HOME}/.nuget/packages,destination=/home/appuser/.nuget/packages,readonly --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name fi diff --git a/scripts/docker/prod-run.sh b/scripts/docker/prod-run.sh index 34295589..7e605ad3 100644 --- a/scripts/docker/prod-run.sh +++ b/scripts/docker/prod-run.sh @@ -11,11 +11,12 @@ env_path=$5 #https://www.powercms.in/article/how-automatically-delete-docker-container-after-running-it #https://www.richard-banks.org/2018/07/debugging-core-in-docker.html #https://docs.docker.com/engine/reference/commandline/run/#mount +#https://docs.docker.com/engine/reference/commandline/run/#env #https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec #https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user # here if we don't use detached mode this task block process for inreactive mode and prevent to use launch debuger in laucnch.json #--rm doesn't work in detached mode #mappings increase the size of docker image so we use it just in debug mode, in prod its better dockerfile restore just nugets it needs for decresing image size if [ -z "$(docker ps -q -f name=${container_name})" ]; then - docker run -it --rm --publish $http_port:80 --publish $https_port:443 --publish-all --env-file $env_path --network=ecommerce --name $container_name --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name + docker run -it --rm --publish $http_port:80 --publish $https_port:443 --publish-all --env-file $env_path --network=food-delivery --name $container_name --mount type=bind,src=${HOME}/.aspnet/https,dst=/https,readonly $image_name fi diff --git a/src/ApiGateway/Directory.Build.props b/src/ApiGateway/Directory.Build.props new file mode 100644 index 00000000..e6d3d16b --- /dev/null +++ b/src/ApiGateway/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/ApiGateway/Dockerfile b/src/ApiGateway/Dockerfile new file mode 100644 index 00000000..3a536f97 --- /dev/null +++ b/src/ApiGateway/Dockerfile @@ -0,0 +1,71 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:latest AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +FROM mcr.microsoft.com/dotnet/sdk:latest AS build +# Setup working directory for the project +WORKDIR /src + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/ApiGateway/Directory.Build.props ./ApiGateway/ +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj ./ApiGateway/FoodDelivery.ApiGateway/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.ApiGateway.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN dotnet restore ./ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/ ./ApiGateway/FoodDelivery.ApiGateway/ + +WORKDIR /src/ApiGateway/FoodDelivery.ApiGateway/ + +RUN dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.ApiGateway.dll"] diff --git a/src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj b/src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj new file mode 100644 index 00000000..972c29c3 --- /dev/null +++ b/src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj @@ -0,0 +1,37 @@ + + + + + enable + enable + + + + true + + + + + + + + gateway + dev + mcr.microsoft.com/dotnet/aspnet:latest + + + + + + + + + + + + + + + + + diff --git a/src/ApiGateway/FoodDelivery.ApiGateway/Program.cs b/src/ApiGateway/FoodDelivery.ApiGateway/Program.cs new file mode 100644 index 00000000..c2aefb84 --- /dev/null +++ b/src/ApiGateway/FoodDelivery.ApiGateway/Program.cs @@ -0,0 +1,65 @@ +using System.IdentityModel.Tokens.Jwt; +using BuildingBlocks.Logging; +using Microsoft.IdentityModel.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SpectreConsole; +using Yarp.ReverseProxy.Transforms; + +Log.Logger = new LoggerConfiguration().MinimumLevel + .Override("Microsoft", LogEventLevel.Information) + .Enrich.FromLogContext() + .WriteTo.SpectreConsole( + "{Timestamp:HH:mm:ss} [{Level:u4}] {Message:lj}{NewLine}{Exception}", + LogEventLevel.Information + ) + .CreateLogger(); + +var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog(); + +JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + +// https://docs.duendesoftware.com/identityserver/v5/bff/apis/remote/ +// https://microsoft.github.io/reverse-proxy/articles/index.html +// https://microsoft.github.io/reverse-proxy/articles/authn-authz.html +// https://microsoft.github.io/reverse-proxy/articles/transforms.html +builder.Services + .AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("yarp")) + // .AddTransforms() + .AddTransforms(transforms => + { + // https://microsoft.github.io/reverse-proxy/articles/transforms.html + transforms.AddRequestTransform(transform => + { + var requestId = Guid.NewGuid().ToString("N"); + var correlationId = Guid.NewGuid().ToString("N"); + + transform.ProxyRequest.Headers.Add("X-Request-InternalCommandId", requestId); + transform.ProxyRequest.Headers.Add("X-Correlation-InternalCommandId", correlationId); + + return ValueTask.CompletedTask; + }); + }); + +var app = builder.Build(); + +// request logging just log in information level and above as default +app.UseSerilogRequestLogging(opts => +{ + opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest; + opts.GetLevel = LogEnricher.GetLogLevel; +}); + +app.MapGet( + "/", + async (HttpContext context) => + { + await context.Response.WriteAsync($"FoodDelivery Gateway"); + } +); + +app.MapReverseProxy(); + +await app.RunAsync(); diff --git a/src/ApiGateway/FoodDelivery.ApiGateway/Properties/launchSettings.json b/src/ApiGateway/FoodDelivery.ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..fa4227a8 --- /dev/null +++ b/src/ApiGateway/FoodDelivery.ApiGateway/Properties/launchSettings.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json.schemaecommerce.org/launchsettings.json", + "profiles": { + "ApiGateway.Https": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadProfile": "aspnetcore", + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:3010;http://localhost:3000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ApiGateway.Http": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadProfile": "aspnetcore", + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:3000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ApiGateway.Watch": { + "commandName": "Project", + "executablePath": "dotnet", + "workingDirectory": "$(ProjectDir)", + "hotReloadEnabled": true, + "hotReloadProfile": "aspnetcore", + "commandLineArgs": "watch -lp ApiGateway.Http", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ApiGateway.LiveRecompilation": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:3000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.docker.json b/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.docker.json new file mode 100644 index 00000000..1016c407 --- /dev/null +++ b/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.docker.json @@ -0,0 +1,30 @@ +{ + "yarp": { + "clusters": { + "catalogs": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { + "destination1": { + "address": "http://catalogs:80" + } + } + }, + "identity": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { + "destination1": { + "address": "http://identity:80" + } + } + }, + "customers": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { + "destination1": { + "address": "http://host.docker.internal:8000" + } + } + } + } + } +} diff --git a/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.json b/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.json new file mode 100644 index 00000000..8d9be11d --- /dev/null +++ b/src/ApiGateway/FoodDelivery.ApiGateway/appsettings.json @@ -0,0 +1,131 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "yarp": { + "routes": { + "catalogs": { + "clusterId": "catalogs", + "match": { + "path": "/api/{version}/catalogs/{**remainder}" + }, + "transforms": [ + { + "PathPattern": "/api/{version}/catalogs/{**remainder}" + }, + { + "RequestHeadersCopy": "true" + }, + { + "RequestHeaderOriginalHost": "true" + }, + { + "X-Forwarded": "Set", + "For": "Remove", + "Proto": "Append", + "Prefix": "Off", + "HeaderPrefix": "X-Forwarded-" + }, + { + "Forwarded": "by,for,host,proto", + "ByFormat": "Random", + "ForFormat": "IpAndPort", + "Action": "Append" + }, + { "ResponseHeadersCopy": "true" } + ] + }, + "identity": { + "clusterId": "identity", + "match": { + "path": "/api/{version}/identity/{**remainder}" + }, + "transforms": [ + { + "PathPattern": "/api/{version}/identity/{**remainder}" + }, + { + "RequestHeadersCopy": "true" + }, + { + "RequestHeaderOriginalHost": "true" + }, + { + "X-Forwarded": "Set", + "For": "Remove", + "Proto": "Append", + "Prefix": "Off", + "HeaderPrefix": "X-Forwarded-" + }, + { + "Forwarded": "by,for,host,proto", + "ByFormat": "Random", + "ForFormat": "IpAndPort", + "Action": "Append" + }, + { "ResponseHeadersCopy": "true" } + ] + }, + "customers": { + "clusterId": "customers", + "match": { + "path": "/api/{version}/customers/{**remainder}" + }, + "transforms": [ + { + "PathPattern": "/api/{version}/customers/{**remainder}" + }, + { + "RequestHeadersCopy": "true" + }, + { + "RequestHeaderOriginalHost": "true" + }, + { + "X-Forwarded": "Set", + "For": "Remove", + "Proto": "Append", + "Prefix": "Off", + "HeaderPrefix": "X-Forwarded-" + }, + { + "Forwarded": "by,for,host,proto", + "ByFormat": "Random", + "ForFormat": "IpAndPort", + "Action": "Append" + }, + { "ResponseHeadersCopy": "true" } + ] + } + }, + "clusters": { + "catalogs": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { + "destination1": { + "address": "http://localhost:4000" + } + } + }, + "identity": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { + "destination1": { + "address": "http://localhost:7000" + } + } + }, + "customers": { + "loadBalancingPolicy": "RoundRobin", + "destinations": { "destination1": { + "address": "http://localhost:8000" + } + } + } + } + } +} diff --git a/src/ApiGateway/dev.Dockerfile b/src/ApiGateway/dev.Dockerfile new file mode 100644 index 00000000..c826076b --- /dev/null +++ b/src/ApiGateway/dev.Dockerfile @@ -0,0 +1,74 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:latest AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +FROM mcr.microsoft.com/dotnet/sdk:latest AS build +# Setup working directory for the project +WORKDIR /src + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/ApiGateway/Directory.Build.props ./ApiGateway/ +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj ./ApiGateway/FoodDelivery.ApiGateway/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.ApiGateway.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN --mount=type=cache,id=gateway_nuget,target=/root/.nuget/packages \ + dotnet restore ./ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/ ./ApiGateway/FoodDelivery.ApiGateway/ + +WORKDIR /src/ApiGateway/FoodDelivery.ApiGateway/ + +RUN --mount=type=cache,id=gateway_nuget,target=/root/.nuget/packages\ + dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN --mount=type=cache,id=gateway_nuget,target=/root/.nuget/packages\ + dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.ApiGateway.dll"] diff --git a/src/ApiGateway/watch.Dockerfile b/src/ApiGateway/watch.Dockerfile new file mode 100644 index 00000000..0d827bb4 --- /dev/null +++ b/src/ApiGateway/watch.Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS builder + +# Setup working directory for the project +WORKDIR /src + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ + +COPY ./src/ApiGateway/Directory.Build.props ./ApiGateway/ +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj ./ApiGateway/FoodDelivery.ApiGateway/ + +# Restore nuget packages +RUN dotnet restore ./ApiGateway/FoodDelivery.ApiGateway/FoodDelivery.ApiGateway.csproj + +# Copy project files +COPY ./src/ApiGateway/FoodDelivery.ApiGateway/ ./ApiGateway/FoodDelivery.ApiGateway/ + +RUN ls + +WORKDIR /src/ApiGateway/FoodDelivery.ApiGateway/ + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilation + +RUN dotnet watch run FoodDelivery.ApiGateway.csproj --launch-profile ApiGateway.LiveRecompilation diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj b/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj index d2045137..3ef5276b 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj @@ -21,12 +21,12 @@ + - diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommand.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommand.cs index b083a150..c4988380 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommand.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommand.cs @@ -2,7 +2,7 @@ namespace BuildingBlocks.Abstractions.CQRS.Commands; -public interface ICommand : ICommand { } +public interface ICommand : IRequest { } public interface ICommand : IRequest where T : notnull { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandHandler.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandHandler.cs index 118d5fe0..71d01051 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandHandler.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandHandler.cs @@ -2,8 +2,8 @@ namespace BuildingBlocks.Abstractions.CQRS.Commands; -public interface ICommandHandler : ICommandHandler - where TCommand : ICommand { } +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand { } public interface ICommandHandler : IRequestHandler where TCommand : ICommand diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandProcessor.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandProcessor.cs index bb0a54de..98aa7699 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandProcessor.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ICommandProcessor.cs @@ -3,7 +3,10 @@ namespace BuildingBlocks.Abstractions.CQRS.Commands; public interface ICommandProcessor { Task SendAsync(ICommand command, CancellationToken cancellationToken = default) - where TResult : notnull; + where TResult : class; + + Task SendAsync(TRequest command, CancellationToken cancellationToken = default) + where TRequest : ICommand; Task ScheduleAsync(IInternalCommand internalCommandCommand, CancellationToken cancellationToken = default); Task ScheduleAsync(IInternalCommand[] internalCommandCommands, CancellationToken cancellationToken = default); diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/IInternalCommandMapper.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/IInternalCommandMapper.cs index cab96e5e..8133cf03 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/IInternalCommandMapper.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/IInternalCommandMapper.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; namespace BuildingBlocks.Abstractions.CQRS.Commands; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ITxCommand.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ITxCommand.cs index 4772c4c4..badd0d58 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ITxCommand.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Commands/ITxCommand.cs @@ -3,7 +3,7 @@ namespace BuildingBlocks.Abstractions.CQRS.Commands; -public interface ITxCommand : ITxCommand { } +public interface ITxCommand : ICommand, ITxRequest { } public interface ITxCommand : ICommand, ITxRequest where T : notnull { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Queries/IPageQuery.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Queries/IPageQuery.cs new file mode 100644 index 00000000..c30518b8 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/CQRS/Queries/IPageQuery.cs @@ -0,0 +1,6 @@ +using BuildingBlocks.Abstractions.Core.Paging; + +namespace BuildingBlocks.Abstractions.CQRS.Queries; + +public interface IPageQuery : IPageRequest, IQuery + where TResponse : notnull { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/ICacheQuery.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/ICacheQuery.cs new file mode 100644 index 00000000..17d7018f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/ICacheQuery.cs @@ -0,0 +1,7 @@ +using BuildingBlocks.Abstractions.CQRS.Queries; + +namespace BuildingBlocks.Abstractions.Caching; + +public interface ICacheQuery : IQuery, ICacheRequest + where TResponse : class + where TRequest : IQuery { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/IInvalidateCacheRequest.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/IInvalidateCacheRequest.cs index be968d26..9d83ba87 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/IInvalidateCacheRequest.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Caching/IInvalidateCacheRequest.cs @@ -11,3 +11,10 @@ public interface IInvalidateCacheRequest public interface IInvalidateCacheRequest : IInvalidateCacheRequest where TRequest : IRequest { } + +public interface IStreamInvalidateCacheRequest + where TRequest : IStreamRequest +{ + string Prefix { get; } + IEnumerable CacheKeys(TRequest request); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageList.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageList.cs new file mode 100644 index 00000000..15316d27 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageList.cs @@ -0,0 +1,24 @@ +using AutoMapper; + +namespace BuildingBlocks.Abstractions.Core.Paging; + +public interface IPageList + where T : class +{ + int CurrentPageSize { get; } + int CurrentStartIndex { get; } + int CurrentEndIndex { get; } + int TotalPages { get; } + bool HasPrevious { get; } + bool HasNext { get; } + IReadOnlyList Items { get; init; } + int TotalCount { get; init; } + int PageNumber { get; init; } + int PageSize { get; init; } + + IPageList MapTo(Func map) + where TR : class; + + public IPageList MapTo(IMapper mapper) + where TR : class; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageRequest.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageRequest.cs new file mode 100644 index 00000000..703bd6c0 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Core/Paging/IPageRequest.cs @@ -0,0 +1,9 @@ +namespace BuildingBlocks.Abstractions.Core.Paging; + +public interface IPageRequest +{ + int PageNumber { get; init; } + int PageSize { get; init; } + string? Filters { get; init; } + string? SortOrder { get; init; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs index d714cec1..a3350285 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs @@ -1,5 +1,3 @@ -using Ardalis.GuardClauses; - namespace BuildingBlocks.Abstractions.Domain; public record AggregateId : Identity @@ -23,7 +21,10 @@ protected AggregateId(long value) : base(value) { } // validations should be placed here instead of constructor - public static new AggregateId CreateAggregateId(long value) => new(Guard.Against.NegativeOrZero(value)); + public new static AggregateId CreateAggregateId(long value) + { + return new AggregateId(value); + } public static implicit operator long(AggregateId id) => id.Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/EntityId.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/EntityId.cs index 997bc720..8dec9acf 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/EntityId.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/EntityId.cs @@ -1,17 +1,23 @@ -using Ardalis.GuardClauses; - namespace BuildingBlocks.Abstractions.Domain; public record EntityId : Identity { - public static implicit operator T(EntityId id) => Guard.Against.Null(id.Value, nameof(id.Value)); + public static implicit operator T(EntityId id) + { + ArgumentNullException.ThrowIfNull(id.Value); + return id.Value; + } - public static EntityId CreateEntityId(T id) => new() { Value = id }; + public static EntityId Of(T id) => new() { Value = id }; } public record EntityId : EntityId { - public static implicit operator long(EntityId id) => Guard.Against.Null(id.Value, nameof(id.Value)); + public static implicit operator long(EntityId id) + { + ArgumentNullException.ThrowIfNull(id.Value); + return id.Value; + } - public static new EntityId CreateEntityId(long id) => new() { Value = id }; + public new static EntityId Of(long id) => new() { Value = id }; } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IAggregatesDomainEventsRequestStore.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IAggregatesDomainEventsRequestStore.cs new file mode 100644 index 00000000..206ccb7f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IAggregatesDomainEventsRequestStore.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IAggregatesDomainEventsRequestStore +{ + IReadOnlyList AddEventsFromAggregate(T aggregate) + where T : IHaveAggregate; + + void AddEvents(IReadOnlyList events); + + IReadOnlyList GetAllUncommittedEvents(); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IDomainEventsAccessor.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IDomainEventsAccessor.cs new file mode 100644 index 00000000..73504f2d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IDomainEventsAccessor.cs @@ -0,0 +1,8 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IDomainEventsAccessor +{ + IReadOnlyList UnCommittedDomainEvents { get; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEvent.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEvent.cs new file mode 100644 index 00000000..848b5b40 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEvent.cs @@ -0,0 +1,31 @@ +using MediatR; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +/// +/// The event interface. +/// +public interface IEvent : INotification +{ + /// + /// Gets the event identifier. + /// + Guid EventId { get; } + + /// + /// Gets the event/aggregate root version. + /// + long EventVersion { get; } + + /// + /// Gets the date the occurred on. + /// + DateTime OccurredOn { get; } + + DateTimeOffset TimeStamp { get; } + + /// + /// Gets type of this event. + /// + public string EventType { get; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventHandler.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventHandler.cs new file mode 100644 index 00000000..3de13983 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventHandler.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IEventHandler : INotificationHandler + where TEvent : INotification { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventMapper.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventMapper.cs new file mode 100644 index 00000000..6a8699e0 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IEventMapper.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IEventMapper : IIDomainNotificationEventMapper, IIntegrationEventMapper { } + +public interface IIDomainNotificationEventMapper +{ + IReadOnlyList? MapToDomainNotificationEvents(IReadOnlyList domainEvents); + IDomainNotificationEvent? MapToDomainNotificationEvent(IDomainEvent domainEvent); +} + +public interface IIntegrationEventMapper +{ + IReadOnlyList? MapToIntegrationEvents(IReadOnlyList domainEvents); + IIntegrationEvent? MapToIntegrationEvent(IDomainEvent domainEvent); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IHaveDomainEvents.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IHaveDomainEvents.cs new file mode 100644 index 00000000..5bca6a1f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IHaveDomainEvents.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IHaveDomainEvents +{ + /// + /// Does the aggregate have change that have not been committed to storage + /// + /// + public bool HasUncommittedDomainEvents(); + + /// + /// Gets a list of uncommitted events for this aggregate. + /// + /// + IReadOnlyList GetUncommittedDomainEvents(); + + /// + /// Remove all domain events + /// + void ClearDomainEvents(); + + /// + /// Mark all changes (events) as committed, clears uncommitted changes and updates the current version of the aggregate. + /// + void MarkUncommittedDomainEventAsCommitted(); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IInternalEventBus.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IInternalEventBus.cs new file mode 100644 index 00000000..916e7dad --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/IInternalEventBus.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Abstractions.Persistence.EventStore; + +namespace BuildingBlocks.Abstractions.Domain.Events; + +public interface IInternalEventBus +{ + Task Publish(IEvent @event, CancellationToken ct); + Task Publish(IMessage @event, CancellationToken ct); + + Task Publish(IStreamEvent @event, CancellationToken ct); + Task Publish(IStreamEvent @event, CancellationToken ct) + where T : IDomainEvent; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEvent.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEvent.cs new file mode 100644 index 00000000..425af100 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEvent.cs @@ -0,0 +1,15 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +/// +/// The domain event interface. +/// +public interface IDomainEvent : IEvent +{ + /// + /// Gets the identifier of the aggregate which has generated the event. + /// + dynamic AggregateId { get; } + long AggregateSequenceNumber { get; } + + public IDomainEvent WithAggregate(dynamic aggregateId, long version); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventContext.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventContext.cs new file mode 100644 index 00000000..37fd70e7 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventContext.cs @@ -0,0 +1,7 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainEventContext +{ + IReadOnlyList GetAllUncommittedEvents(); + void MarkUncommittedDomainEventAsCommitted(); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventHandler.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventHandler.cs new file mode 100644 index 00000000..255630a5 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventHandler.cs @@ -0,0 +1,4 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainEventHandler : IEventHandler + where TEvent : IDomainEvent { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventPublisher.cs new file mode 100644 index 00000000..0d0660e4 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainEventPublisher.cs @@ -0,0 +1,7 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainEventPublisher +{ + Task PublishAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default); + Task PublishAsync(IDomainEvent[] domainEvents, CancellationToken cancellationToken = default); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEvent.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEvent.cs new file mode 100644 index 00000000..79b89e2f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEvent.cs @@ -0,0 +1,9 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainNotificationEvent : IDomainNotificationEvent + where TDomainEventType : IDomainEvent +{ + TDomainEventType DomainEvent { get; set; } +} + +public interface IDomainNotificationEvent : IEvent { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventHandler.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventHandler.cs new file mode 100644 index 00000000..fbf46dca --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventHandler.cs @@ -0,0 +1,4 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainNotificationEventHandler : IEventHandler + where TEvent : IDomainNotificationEvent { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventPublisher.cs new file mode 100644 index 00000000..24efa64b --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IDomainNotificationEventPublisher.cs @@ -0,0 +1,11 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IDomainNotificationEventPublisher +{ + Task PublishAsync(IDomainNotificationEvent domainNotificationEvent, CancellationToken cancellationToken = default); + + Task PublishAsync( + IDomainNotificationEvent[] domainNotificationEvents, + CancellationToken cancellationToken = default + ); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IHaveNotificationEvent.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IHaveNotificationEvent.cs new file mode 100644 index 00000000..e2b50907 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/Events/Internal/IHaveNotificationEvent.cs @@ -0,0 +1,3 @@ +namespace BuildingBlocks.Abstractions.Domain.Events.Internal; + +public interface IHaveNotificationEvent { } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IBusinessRuleWithExceptionType.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IBusinessRuleWithExceptionType.cs new file mode 100644 index 00000000..2b1463e4 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IBusinessRuleWithExceptionType.cs @@ -0,0 +1,15 @@ +namespace BuildingBlocks.Abstractions.Domain; + +public interface IBusinessRule +{ + bool IsBroken(); + string Message { get; } + int Status { get; } +} + +public interface IBusinessRuleWithExceptionType + where TException : Exception +{ + bool IsBroken(); + TException Exception { get; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IHaveAggregate.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IHaveAggregate.cs index 6aea915e..1dcee4c7 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IHaveAggregate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/IHaveAggregate.cs @@ -1,3 +1,12 @@ +using BuildingBlocks.Abstractions.Domain.Events; + namespace BuildingBlocks.Abstractions.Domain; -public interface IHaveAggregate : IHaveDomainEvents, IHaveAggregateVersion { } +public interface IHaveAggregate : IHaveDomainEvents, IHaveAggregateVersion +{ + /// + /// Check specific rule for aggregate and throw an exception if rule is not satisfied. + /// + /// + void CheckRule(IBusinessRule rule); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IIntegrationEventHandler.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IIntegrationEventHandler.cs index da7df5c3..e9a4575a 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IIntegrationEventHandler.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IIntegrationEventHandler.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events; +using BuildingBlocks.Abstractions.Domain.Events; namespace BuildingBlocks.Abstractions.Messaging; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs index ba28dc8c..19268a67 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; using BuildingBlocks.Abstractions.CQRS.Commands; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; namespace BuildingBlocks.Abstractions.Messaging.PersistMessage; @@ -9,6 +9,9 @@ namespace BuildingBlocks.Abstractions.Messaging.PersistMessage; // Ref: https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/ // Ref: https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/subscribe-events#designing-atomicity-and-resiliency-when-publishing-to-the-event-bus // Ref: https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing +// Ref: https://learn.microsoft.com/en-us/azure/service-bus-messaging/duplicate-detection +// Ref: https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-queues-topics-subscriptions#receive-modes +// https://exactly-once.github.io/posts/exactly-once-delivery/ public interface IMessagePersistenceService { Task> GetByFilterAsync( diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IHaveEventSourcingAggregate.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IHaveEventSourcingAggregate.cs index 55ec887e..a164722d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IHaveEventSourcingAggregate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IHaveEventSourcingAggregate.cs @@ -1,5 +1,5 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Domain.EventSourcing; using BuildingBlocks.Abstractions.Persistence.EventStore.Projections; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEvent.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEvent.cs index 3247826c..dfb771d9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEvent.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEvent.cs @@ -1,17 +1,15 @@ -using BuildingBlocks.Abstractions.CQRS.Events; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; namespace BuildingBlocks.Abstractions.Persistence.EventStore; -public interface IStreamEvent : IEvent +public interface IStreamEvent { - public IDomainEvent Data { get; } - - public IStreamEventMetadata? Metadata { get; } + object Data { get; } + IStreamEventMetadata Metadata { get; init; } } public interface IStreamEvent : IStreamEvent where T : IDomainEvent { - public new T Data { get; } + new T Data { get; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEventMetadata.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEventMetadata.cs index bb455a39..78be3428 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEventMetadata.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/IStreamEventMetadata.cs @@ -1,8 +1,11 @@ +using OpenTelemetry.Context.Propagation; + namespace BuildingBlocks.Abstractions.Persistence.EventStore; public interface IStreamEventMetadata { string EventId { get; } - long? LogPosition { get; } - long StreamPosition { get; } + ulong? LogPosition { get; } + ulong StreamPosition { get; } + PropagationContext? PropagationContext { get; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IHaveReadProjection.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IHaveReadProjection.cs index bbe692a6..3a7f2953 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IHaveReadProjection.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IHaveReadProjection.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; namespace BuildingBlocks.Abstractions.Persistence.EventStore.Projections; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IReadProjectionPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IReadProjectionPublisher.cs index dffc340f..56575fc2 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IReadProjectionPublisher.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/EventStore/Projections/IReadProjectionPublisher.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; namespace BuildingBlocks.Abstractions.Persistence.EventStore.Projections; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IDbExecutors.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IDbExecutors.cs new file mode 100644 index 00000000..dd4c97da --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IDbExecutors.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Abstractions.Persistence; + +public interface IDbExecutors +{ + public void Register(IServiceCollection services); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IEventRepository.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IEventRepository.cs index 9414e712..16294759 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IEventRepository.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IEventRepository.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events; +using BuildingBlocks.Abstractions.Domain.Events; namespace BuildingBlocks.Abstractions.Persistence { diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationExecutor.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationExecutor.cs new file mode 100644 index 00000000..fa26c0f0 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationExecutor.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Abstractions.Persistence; + +public interface IMigrationExecutor +{ + Task ExecuteAsync(CancellationToken cancellationToken); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationManager.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationManager.cs new file mode 100644 index 00000000..9d7c0c9e --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IMigrationManager.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Abstractions.Persistence; + +public interface IMigrationManager +{ + Task ExecuteAsync(CancellationToken cancellationToken); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IRepository.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IRepository.cs index 71709979..f5471427 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IRepository.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Persistence/IRepository.cs @@ -1,4 +1,7 @@ using System.Linq.Expressions; +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; using BuildingBlocks.Abstractions.Domain; namespace BuildingBlocks.Abstractions.Persistence; @@ -18,7 +21,32 @@ Task> FindAsync( CancellationToken cancellationToken = default ); + Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + + IAsyncEnumerable ProjectBy( + IConfigurationProvider configuration, + Expression>? predicate = null, + Expression>? sortExpression = null, + CancellationToken cancellationToken = default + ); + + Task> GetByPageFilter( + IPageRequest pageRequest, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default + ); + + Task> GetByPageFilter( + IPageRequest pageRequest, + IConfigurationProvider configuration, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default + ) + where TResult : class; } public interface IWriteRepository diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpCommand.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpCommand.cs new file mode 100644 index 00000000..2bcb2130 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpCommand.cs @@ -0,0 +1,27 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Abstractions.Web.MinimalApi; + +// https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation +// https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ +public interface IHttpCommand +{ + TRequest Request { get; init; } + HttpContext HttpContext { get; init; } + ICommandProcessor CommandProcessor { get; init; } + IMapper Mapper { get; init; } + CancellationToken CancellationToken { get; init; } +} + +public interface IHttpCommand +{ + HttpContext HttpContext { get; init; } + ICommandProcessor CommandProcessor { get; init; } + IMapper Mapper { get; init; } + CancellationToken CancellationToken { get; init; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpQuery.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpQuery.cs new file mode 100644 index 00000000..3a9dacc2 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IHttpQuery.cs @@ -0,0 +1,18 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Abstractions.Web.MinimalApi; + +// https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation +// https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ +public interface IHttpQuery +{ + HttpContext HttpContext { get; init; } + IQueryProcessor QueryProcessor { get; init; } + IMapper Mapper { get; init; } + CancellationToken CancellationToken { get; init; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IMinimalEndpoint.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IMinimalEndpoint.cs index 80823dfb..239dd7f2 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IMinimalEndpoint.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/MinimalApi/IMinimalEndpoint.cs @@ -1,8 +1,6 @@ -using AutoMapper; -using BuildingBlocks.Abstractions.CQRS.Commands; -using BuildingBlocks.Abstractions.CQRS.Queries; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; namespace BuildingBlocks.Abstractions.Web.MinimalApi; @@ -59,96 +57,88 @@ CancellationToken cancellationToken ); } -public interface ICommandMinimalEndpoint : IMinimalEndpoint +public interface ICommandMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpCommand { - Task HandleAsync( - HttpContext context, - TRequest request, - ICommandProcessor commandProcessor, - IMapper mapper, - CancellationToken cancellationToken - ); + Task HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface ICommandMinimalEndpoint : IMinimalEndpoint +public interface ICommandMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpCommand + where TResult1 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - ICommandProcessor commandProcessor, - IMapper mapper, - CancellationToken cancellationToken - ); + Task HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface ICommandMinimalEndpoint : IMinimalEndpoint +public interface ICommandMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpCommand + where TResult1 : IResult + where TResult2 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - ICommandProcessor commandProcessor, - IMapper mapper, - TDependency dependency1, - CancellationToken cancellationToken - ); + Task> HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface ICommandMinimalEndpoint : IMinimalEndpoint +public interface ICommandMinimalEndpoint + : IMinimalEndpoint + where TRequestParameters : IHttpCommand + where TResult1 : IResult + where TResult2 : IResult + where TResult3 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - ICommandProcessor commandProcessor, - IMapper mapper, - TDependency1 dependency1, - TDependency2 dependency2, - CancellationToken cancellationToken - ); + Task> HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface IQueryMinimalEndpoint : IMinimalEndpoint +public interface ICommandMinimalEndpoint + : IMinimalEndpoint + where TRequestParameters : IHttpCommand + where TResult1 : IResult + where TResult2 : IResult + where TResult3 : IResult + where TResult4 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - IQueryProcessor queryProcessor, - IMapper mapper, - CancellationToken cancellationToken + Task> HandleAsync( + [AsParameters] TRequestParameters requestParameters ); } -public interface IQueryMinimalEndpoint : IMinimalEndpoint +public interface IQueryMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpQuery { - Task HandleAsync( - HttpContext context, - TRequest request, - IQueryProcessor queryProcessor, - IMapper mapper, - CancellationToken cancellationToken - ); + Task HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface IQueryMinimalEndpoint : IMinimalEndpoint +public interface IQueryMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpQuery + where TResult1 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - IQueryProcessor queryProcessor, - IMapper mapper, - TDependency dependency, - CancellationToken cancellationToken - ); + Task HandleAsync([AsParameters] TRequestParameters requestParameters); } -public interface IQueryMinimalEndpoint : IMinimalEndpoint +public interface IQueryMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpQuery + where TResult1 : IResult + where TResult2 : IResult { - Task HandleAsync( - HttpContext context, - TRequest request, - IQueryProcessor queryProcessor, - IMapper mapper, - TDependency1 dependency1, - TDependency2 dependency2, - CancellationToken cancellationToken + Task> HandleAsync([AsParameters] TRequestParameters requestParameters); +} + +public interface IQueryMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpQuery + where TResult1 : IResult + where TResult2 : IResult + where TResult3 : IResult +{ + Task> HandleAsync([AsParameters] TRequestParameters requestParameters); +} + +public interface IQueryMinimalEndpoint : IMinimalEndpoint + where TRequestParameters : IHttpQuery + where TResult1 : IResult + where TResult2 : IResult + where TResult3 : IResult + where TResult4 : IResult +{ + Task> HandleAsync( + [AsParameters] TRequestParameters requestParameters ); } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/Problem/IProblemDetailMapper.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/Problem/IProblemDetailMapper.cs new file mode 100644 index 00000000..e2a965ab --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Web/Problem/IProblemDetailMapper.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Abstractions.Web.Problem; + +public interface IProblemDetailMapper +{ + int GetMappedStatusCodes(Exception exception); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/CachingBehavior.cs b/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/CachingBehavior.cs index ff201f4c..cf145e3f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/CachingBehavior.cs +++ b/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/CachingBehavior.cs @@ -1,4 +1,3 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Caching; using EasyCaching.Core; using MediatR; @@ -10,27 +9,21 @@ namespace BuildingBlocks.Caching.Behaviours; // Ref: https://anderly.com/2019/12/12/cross-cutting-concerns-with-mediatr-pipeline-behaviors/ public class CachingBehavior : IPipelineBehavior where TRequest : IRequest - where TResponse : notnull + where TResponse : class { private readonly ILogger> _logger; private readonly IEasyCachingProvider _cacheProvider; - private readonly IEnumerable> _cachePolicies; + private readonly CacheOptions _cacheOptions; public CachingBehavior( ILogger> logger, IEasyCachingProviderFactory cachingProviderFactory, - IOptions cacheOptions, - IEnumerable> cachePolicies + IOptions cacheOptions ) { - _logger = Guard.Against.Null(logger); - Guard.Against.Null(cacheOptions.Value); - _cacheProvider = Guard.Against - .Null(cachingProviderFactory) - .GetCachingProvider(cacheOptions.Value.DefaultCacheType); - - // cachePolicies inject like `FluentValidation` approach as a nested or seperated cache class for commands ,queries - _cachePolicies = cachePolicies; + _cacheOptions = cacheOptions.Value; + _logger = logger; + _cacheProvider = cachingProviderFactory.GetCachingProvider(cacheOptions.Value.DefaultCacheType); } public async Task Handle( @@ -39,8 +32,7 @@ public async Task Handle( CancellationToken cancellationToken ) { - var cacheRequest = _cachePolicies.FirstOrDefault(); - if (cacheRequest == null) + if (request is not ICacheRequest cacheRequest) { // No cache policy found, so just continue through the pipeline return await next(); @@ -61,12 +53,14 @@ CancellationToken cancellationToken var response = await next(); - await _cacheProvider.SetAsync( - cacheKey, - response, - cacheRequest.AbsoluteExpirationRelativeToNow, - cancellationToken - ); + var expiredTimeSpan = + cacheRequest.AbsoluteExpirationRelativeToNow != TimeSpan.FromMinutes(5) + ? cacheRequest.AbsoluteExpirationRelativeToNow + : TimeSpan.FromMinutes(_cacheOptions.ExpirationTimeInMinute) != TimeSpan.FromMinutes(5) + ? TimeSpan.FromMinutes(_cacheOptions.ExpirationTimeInMinute) + : cacheRequest.AbsoluteExpirationRelativeToNow; + + await _cacheProvider.SetAsync(cacheKey, response, expiredTimeSpan, cancellationToken); _logger.LogDebug( "Caching response for {TRequest} with cache key: {CacheKey}", @@ -79,44 +73,43 @@ await _cacheProvider.SetAsync( } public class StreamCachingBehavior : IStreamPipelineBehavior - where TRequest : IStreamRequest, IRequest - where TResponse : notnull + where TRequest : IStreamRequest + where TResponse : class { private readonly ILogger> _logger; - private readonly IEnumerable> _cachePolicies; private readonly IEasyCachingProvider _cacheProvider; + private readonly CacheOptions _cacheOptions; public StreamCachingBehavior( ILogger> logger, IEasyCachingProviderFactory cachingProviderFactory, - IOptions cacheOptions, - IEnumerable> cachePolicies + IOptions cacheOptions ) { - _logger = Guard.Against.Null(logger); - Guard.Against.Null(cacheOptions.Value); - _cacheProvider = Guard.Against - .Null(cachingProviderFactory) - .GetCachingProvider(cacheOptions.Value.DefaultCacheType); - - // cachePolicies inject like `FluentValidation` approach as a nested or seperated cache class for commands ,queries - _cachePolicies = cachePolicies; + _cacheOptions = cacheOptions.Value; + _logger = logger; + _cacheProvider = cachingProviderFactory.GetCachingProvider(cacheOptions.Value.DefaultCacheType); } - public IAsyncEnumerable Handle( + public async IAsyncEnumerable Handle( TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken ) { - var cacheRequest = _cachePolicies.FirstOrDefault(); - if (cacheRequest == null) + if (request is not IStreamCacheRequest cacheRequest) { - return next(); + // If the request does not implement IStreamCacheRequest, go to the next pipeline + await foreach (var response in next().WithCancellation(cancellationToken)) + { + yield return response; + } + + yield break; } var cacheKey = cacheRequest.CacheKey(request); - var cachedResponse = _cacheProvider.GetAsync(cacheKey, cancellationToken); + var cachedResponse = _cacheProvider.Get(cacheKey); if (cachedResponse != null) { @@ -125,22 +118,29 @@ CancellationToken cancellationToken typeof(TRequest).FullName, cacheKey ); - return next(); + + yield return cachedResponse.Value; + yield break; } - var response = next(); + var expiredTimeSpan = + cacheRequest.AbsoluteExpirationRelativeToNow != TimeSpan.FromMinutes(5) + ? cacheRequest.AbsoluteExpirationRelativeToNow + : TimeSpan.FromMinutes(_cacheOptions.ExpirationTimeInMinute) != TimeSpan.FromMinutes(5) + ? TimeSpan.FromMinutes(_cacheOptions.ExpirationTimeInMinute) + : cacheRequest.AbsoluteExpirationRelativeToNow; - _cacheProvider - .SetAsync(cacheKey, response, cacheRequest.AbsoluteExpirationRelativeToNow, cancellationToken) - .GetAwaiter() - .GetResult(); + await foreach (var response in next().WithCancellation(cancellationToken)) + { + _cacheProvider.SetAsync(cacheKey, response, expiredTimeSpan, cancellationToken).GetAwaiter().GetResult(); - _logger.LogDebug( - "Caching response for {TRequest} with cache key: {CacheKey}", - typeof(TRequest).FullName, - cacheKey - ); + _logger.LogDebug( + "Caching response for {TRequest} with cache key: {CacheKey}", + typeof(TRequest).FullName, + cacheKey + ); - return response; + yield return response; + } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/InvalidateCachingBehavior.cs b/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/InvalidateCachingBehavior.cs index 958ff471..152d93b6 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/InvalidateCachingBehavior.cs +++ b/src/BuildingBlocks/BuildingBlocks.Caching/Behaviours/InvalidateCachingBehavior.cs @@ -1,5 +1,5 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Caching; +using BuildingBlocks.Core.Extensions; using EasyCaching.Core; using MediatR; using Microsoft.Extensions.Logging; @@ -8,28 +8,20 @@ namespace BuildingBlocks.Caching.Behaviours; public class InvalidateCachingBehavior : IPipelineBehavior - where TRequest : notnull, IRequest - where TResponse : notnull + where TRequest : IRequest + where TResponse : class { private readonly ILogger> _logger; private readonly IEasyCachingProvider _cacheProvider; - private readonly IEnumerable> _invalidateCachingPolicies; public InvalidateCachingBehavior( ILogger> logger, IEasyCachingProviderFactory cachingProviderFactory, - IOptions cacheOptions, - IEnumerable> invalidateCachingPolicies + IOptions cacheOptions ) { - _logger = Guard.Against.Null(logger); - Guard.Against.Null(cacheOptions.Value); - _cacheProvider = Guard.Against - .Null(cachingProviderFactory) - .GetCachingProvider(cacheOptions.Value.DefaultCacheType); - - // cachePolicies inject like `FluentValidation` approach as a nested or seperated cache class for commands ,queries - _invalidateCachingPolicies = invalidateCachingPolicies; + _logger = logger; + _cacheProvider = cachingProviderFactory.GetCachingProvider(cacheOptions.Value.DefaultCacheType); } public async Task Handle( @@ -38,8 +30,7 @@ public async Task Handle( CancellationToken cancellationToken ) { - var cacheRequest = _invalidateCachingPolicies.FirstOrDefault(); - if (cacheRequest == null) + if (request is not IInvalidateCacheRequest cacheRequest) { // No cache policy found, so just continue through the pipeline return await next(); @@ -57,3 +48,52 @@ CancellationToken cancellationToken return response; } } + +public class StreamInvalidateCachingBehavior : IStreamPipelineBehavior + where TRequest : IStreamRequest + where TResponse : class +{ + private readonly ILogger> _logger; + private readonly IEasyCachingProvider _cacheProvider; + + public StreamInvalidateCachingBehavior( + ILogger> logger, + IEasyCachingProviderFactory cachingProviderFactory, + IOptions cacheOptions + ) + { + _logger = logger; + _cacheProvider = cachingProviderFactory.GetCachingProvider(cacheOptions.Value.DefaultCacheType); + } + + public async IAsyncEnumerable Handle( + TRequest request, + StreamHandlerDelegate next, + CancellationToken cancellationToken + ) + { + if (request is not IStreamInvalidateCacheRequest cacheRequest) + { + // If the request does not implement IStreamCacheRequest, go to the next pipeline + await foreach (var response in next().WithCancellation(cancellationToken)) + { + yield return response; + } + + yield break; + } + + await foreach (var response in next().WithCancellation(cancellationToken)) + { + var cacheKeys = cacheRequest.CacheKeys(request); + + foreach (var cacheKey in cacheKeys) + { + await _cacheProvider.RemoveAsync(cacheKey, cancellationToken); + _logger.LogDebug("Cache data with cache key: {CacheKey} invalidated", cacheKey); + } + + yield return response; + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj b/src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj index 8bb0abf0..77ae631f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj @@ -18,3 +18,4 @@ + \ No newline at end of file diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/CacheKey.cs b/src/BuildingBlocks/BuildingBlocks.Caching/CacheKey.cs index a65d1220..5679d6ef 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/CacheKey.cs +++ b/src/BuildingBlocks/BuildingBlocks.Caching/CacheKey.cs @@ -1,6 +1,4 @@ -using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Reflection.Extensions; -using BuildingBlocks.Core.Types.Extensions; namespace BuildingBlocks.Caching; diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/CacheOptions.cs b/src/BuildingBlocks/BuildingBlocks.Caching/CacheOptions.cs index 14a827f5..9c7741c9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/CacheOptions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Caching/CacheOptions.cs @@ -5,7 +5,7 @@ namespace BuildingBlocks.Caching; public class CacheOptions { public string DefaultCacheType { get; set; } = nameof(CacheProviderType.InMemory); - public double ExpirationTime { get; set; } = 3600; + public double ExpirationTimeInMinute { get; set; } = 5; public string SerializationType { get; set; } = nameof(CacheSerializationType.Json); public RedisCacheOptions? RedisCacheOptions { get; set; } = default!; public InMemoryCacheOptions? InMemoryOptions { get; set; } = default!; diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/CacheQuery.cs b/src/BuildingBlocks/BuildingBlocks.Caching/CacheQuery.cs new file mode 100644 index 00000000..f62dedfe --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Caching/CacheQuery.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Abstractions.Caching; +using BuildingBlocks.Abstractions.CQRS.Queries; + +namespace BuildingBlocks.Caching; + +public abstract record CacheQuery : ICacheQuery + where TRequest : IQuery + where TResponse : class +{ + public virtual TimeSpan AbsoluteExpirationRelativeToNow => TimeSpan.FromMinutes(5); + + // public virtual TimeSpan SlidingExpiration => TimeSpan.FromSeconds(30); + // public virtual DateTime? AbsoluteExpiration => null; + public virtual string Prefix => "Ch_"; + + public virtual string CacheKey(TRequest request) => $"{Prefix}{typeof(TRequest).Name}"; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/DatabaseExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Caching/DatabaseExtensions.cs new file mode 100644 index 00000000..f3252558 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Caching/DatabaseExtensions.cs @@ -0,0 +1,74 @@ +using Humanizer; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using StackExchange.Redis; + +namespace BuildingBlocks.Caching; + +public static class DatabaseExtensions +{ + public static async Task PublishMessage(this IDatabase database, string channelName, T data) + { + var jsonData = JsonConvert.SerializeObject( + data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() } + ); + + await database.PublishAsync(channelName, jsonData); + } + + public static async Task PublishMessage(this IDatabase database, T data) + { + var channelName = $"{typeof(T).Name.Underscore()}_channel"; + await database.PublishMessage(channelName, data); + } + + public static async Task PublishMessage(this ITransaction transaction, string channelName, T data) + { + var jsonData = JsonConvert.SerializeObject( + data, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() } + ); + + await transaction.PublishAsync(channelName, jsonData); + } + + public static async Task PublishMessage(this ITransaction transaction, T data) + { + var channelName = $"{typeof(T).Name.Underscore()}_channel"; + await transaction.PublishMessage(channelName, data); + } + + public static async Task SubscribeMessage( + this IDatabase database, + string channelName, + Func handler + ) + { + var channelMessageQueue = await database.Multiplexer.GetSubscriber().SubscribeAsync(channelName); + + channelMessageQueue.OnMessage(async channelMessage => + { + var message = JsonConvert.DeserializeObject(channelMessage.Message!); + await handler(channelMessage.Channel!, message!); + }); + } + + public static async Task SubscribeMessage(this IDatabase database, string channelName, Func handler) + { + var channelMessageQueue = await database.Multiplexer.GetSubscriber().SubscribeAsync(channelName); + + channelMessageQueue.OnMessage(async channelMessage => + { + var message = JsonConvert.DeserializeObject(channelMessage.Message!); + await handler(message!); + }); + } + + public static async Task SubscribeMessage(this IDatabase database, Func handler) + { + var channelName = $"{typeof(T).Name.Underscore()}_channel"; + + await database.SubscribeMessage(channelName, handler); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.Caching/Extensions.cs index d865e0b0..f2722394 100644 --- a/src/BuildingBlocks/BuildingBlocks.Caching/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Caching/Extensions.cs @@ -1,27 +1,26 @@ -using System.Reflection; -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Caching; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Reflection; -using BuildingBlocks.Core.Utils; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using EasyCaching.Redis; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; namespace BuildingBlocks.Caching; public static class Extensions { - public static WebApplicationBuilder AddCustomCaching( + public static WebApplicationBuilder AddCustomEasyCaching( this WebApplicationBuilder builder, - params Assembly[] scanAssemblies + Action? configurator = null ) { // https://www.twilio.com/blog/provide-default-configuration-to-dotnet-applications var cacheOptions = builder.Configuration.BindOptions(); - Guard.Against.Null(cacheOptions); + configurator?.Invoke(cacheOptions); - AddCachingRequests(builder.Services, scanAssemblies); + // add option to the dependency injection + builder.Services.AddValidationOptions(opt => configurator?.Invoke(opt)); builder.Services.AddEasyCaching(option => { @@ -48,54 +47,49 @@ params Assembly[] scanAssemblies nameof(CacheProviderType.InMemory) ); - if (cacheOptions.SerializationType == nameof(CacheSerializationType.Json)) + switch (cacheOptions.SerializationType) { - option.WithJson( - jsonSerializerSettingsConfigure: x => - { - x.TypeNameHandling = Newtonsoft.Json.TypeNameHandling.None; - }, - nameof(CacheSerializationType.Json) - ); - } - else if (cacheOptions.SerializationType == nameof(CacheSerializationType.MessagePack)) - { - option.WithMessagePack(nameof(CacheSerializationType.MessagePack)); + case nameof(CacheSerializationType.Json): + option.WithJson( + jsonSerializerSettingsConfigure: x => + { + x.TypeNameHandling = Newtonsoft.Json.TypeNameHandling.None; + }, + nameof(CacheSerializationType.Json) + ); + break; + case nameof(CacheSerializationType.MessagePack): + option.WithMessagePack(nameof(CacheSerializationType.MessagePack)); + break; } }); return builder; } - private static IServiceCollection AddCachingRequests( - this IServiceCollection services, - params Assembly[] scanAssemblies + public static WebApplicationBuilder AddCustomRedis( + this WebApplicationBuilder builder, + Action? configurator = null ) { - // Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable (it is possible to get ReflectionTypeLoadException, because some dependent type assembly are lazy and not loaded yet), so we use `GetAllReferencedAssemblies` and it - // load all referenced assemblies explicitly. - var assemblies = scanAssemblies.Any() - ? scanAssemblies - : ReflectionUtilities.GetReferencedAssemblies(Assembly.GetCallingAssembly()).ToArray(); + // https://www.twilio.com/blog/provide-default-configuration-to-dotnet-applications + var redisOptions = builder.Configuration.BindOptions(); + configurator?.Invoke(redisOptions); - // ICacheRequest discovery and registration - services.Scan( - scan => - scan.FromAssemblies(assemblies) - .AddClasses(classes => classes.AssignableTo(typeof(ICacheRequest<,>)), false) - .AsImplementedInterfaces() - .WithTransientLifetime() - ); + // add option to the dependency injection + builder.Services.AddValidationOptions(opt => configurator?.Invoke(opt)); - // IInvalidateCacheRequest discovery and registration - services.Scan( - scan => - scan.FromAssemblies(assemblies) - .AddClasses(classes => classes.AssignableTo(typeof(IInvalidateCacheRequest<,>)), false) - .AsImplementedInterfaces() - .WithTransientLifetime() + builder.Services.TryAddSingleton( + sp => + ConnectionMultiplexer.Connect( + new ConfigurationOptions + { + EndPoints = { $"{redisOptions.Host}:{redisOptions.Port}" }, + AllowAdmin = true + } + ) ); - return services; + return builder; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Caching/RedisOptions.cs b/src/BuildingBlocks/BuildingBlocks.Caching/RedisOptions.cs new file mode 100644 index 00000000..e9644c17 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Caching/RedisOptions.cs @@ -0,0 +1,7 @@ +namespace BuildingBlocks.Caching; + +public class RedisOptions +{ + public string Host { get; set; } + public int Port { get; set; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj b/src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj index 9a5061c3..9abca5a3 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj @@ -10,6 +10,7 @@ + @@ -23,6 +24,7 @@ + @@ -36,7 +38,6 @@ - @@ -50,6 +51,9 @@ + + + @@ -59,4 +63,8 @@ + + + + diff --git a/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Commands/CommandProcessor.cs b/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Commands/CommandProcessor.cs index e5ce47c7..53baee39 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Commands/CommandProcessor.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Commands/CommandProcessor.cs @@ -15,10 +15,19 @@ public CommandProcessor(IMediator mediator, IMessagePersistenceService messagePe _messagePersistenceService = messagePersistenceService; } - public Task SendAsync(ICommand command, CancellationToken cancellationToken = default) - where TResult : notnull + public async Task SendAsync( + ICommand command, + CancellationToken cancellationToken = default + ) + where TResult : class + { + return await _mediator.Send(command, cancellationToken); + } + + public async Task SendAsync(TRequest command, CancellationToken cancellationToken = default) + where TRequest : ICommand { - return _mediator.Send(command, cancellationToken); + await _mediator.Send(command, cancellationToken); } public async Task ScheduleAsync( diff --git a/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Queries/PageQuery.cs b/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Queries/PageQuery.cs new file mode 100644 index 00000000..16f43a2f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/CQRS/Queries/PageQuery.cs @@ -0,0 +1,8 @@ +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Paging; + +namespace BuildingBlocks.Core.CQRS.Queries; + +// https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/records#characteristics-of-records +public record PageQuery : PageRequest, IPageQuery + where TResponse : notnull; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Aggregate.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Aggregate.cs index 78ce1e3c..d846d012 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Aggregate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Aggregate.cs @@ -1,10 +1,7 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Core.Domain.Exceptions; namespace BuildingBlocks.Core.Domain; @@ -40,6 +37,11 @@ public IReadOnlyList GetUncommittedDomainEvents() return _uncommittedDomainEvents.ToImmutableList(); } + public void ClearDomainEvents() + { + _uncommittedDomainEvents.Clear(); + } + public IReadOnlyList DequeueUncommittedDomainEvents() { var events = _uncommittedDomainEvents.ToImmutableList(); @@ -54,9 +56,20 @@ public void MarkUncommittedDomainEventAsCommitted() public void CheckRule(IBusinessRule rule) { - if (rule.IsBroken()) + var isBroken = rule.IsBroken(); + if (isBroken) + { + throw new DomainException(rule.GetType(), rule.Message, rule.Status); + } + } + + public void CheckRule(IBusinessRuleWithExceptionType ruleWithExceptionType) + where T : DomainException + { + var isBroken = ruleWithExceptionType.IsBroken(); + if (isBroken) { - throw new BusinessRuleValidationException(rule); + throw ruleWithExceptionType.Exception; } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs index d4f9ebd2..989f6413 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs @@ -4,7 +4,7 @@ namespace BuildingBlocks.Core.Domain; public abstract class Entity : IEntity { - public TId Id { get; protected init; } = default!; + public TId Id { get; protected set; } = default!; public DateTime Created { get; private set; } = default!; public int? CreatedBy { get; private set; } = default!; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/EventSourcing/EventSourcedAggregate.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/EventSourcing/EventSourcedAggregate.cs index ad4ba613..48021fd3 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/EventSourcing/EventSourcedAggregate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/EventSourcing/EventSourcedAggregate.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Domain.EventSourcing; using BuildingBlocks.Core.Domain.Exceptions; using BuildingBlocks.Core.Extensions; @@ -73,8 +73,7 @@ protected void AddDomainEvents(IDomainEvent domainEvent) { if (!_uncommittedDomainEvents.Any(x => Equals(x.EventId, domainEvent.EventId))) { - IDomainEvent eventWithAggregate = domainEvent.WithAggregate(Id, CurrentVersion + 1); - _uncommittedDomainEvents.Enqueue(eventWithAggregate); + _uncommittedDomainEvents.Enqueue(domainEvent); } } @@ -88,6 +87,11 @@ public IReadOnlyList GetUncommittedDomainEvents() return _uncommittedDomainEvents.ToImmutableList(); } + public void ClearDomainEvents() + { + _uncommittedDomainEvents.Clear(); + } + public IReadOnlyList DequeueUncommittedDomainEvents() { var events = _uncommittedDomainEvents.ToImmutableList(); @@ -105,9 +109,20 @@ public void MarkUncommittedDomainEventAsCommitted() public void CheckRule(IBusinessRule rule) { - if (rule.IsBroken()) + var broken = rule.IsBroken(); + if (broken) + { + throw new DomainException(rule.GetType(), rule.Message, rule.Status); + } + } + + public void CheckRule(IBusinessRuleWithExceptionType ruleWithExceptionType) + where T : DomainException + { + var isBroken = ruleWithExceptionType.IsBroken(); + if (isBroken) { - throw new BusinessRuleValidationException(rule); + throw ruleWithExceptionType.Exception; } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/AggregateDomainEventsStore.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/AggregateDomainEventsStore.cs new file mode 100644 index 00000000..f28028f1 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/AggregateDomainEventsStore.cs @@ -0,0 +1,33 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events; + +public class AggregatesDomainEventsStore : IAggregatesDomainEventsRequestStore +{ + private readonly List _uncommittedDomainEvents = new(); + + public IReadOnlyList AddEventsFromAggregate(T aggregate) + where T : IHaveAggregate + { + var events = aggregate.GetUncommittedDomainEvents(); + + AddEvents(events); + + return events; + } + + public void AddEvents(IReadOnlyList events) + { + if (events.Any()) + { + _uncommittedDomainEvents.AddRange(events); + } + } + + public IReadOnlyList GetAllUncommittedEvents() + { + return _uncommittedDomainEvents; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventAccessor.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventAccessor.cs new file mode 100644 index 00000000..7f993e06 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventAccessor.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events; + +public class DomainEventAccessor : IDomainEventsAccessor +{ + private readonly IDomainEventContext _domainEventContext; + private readonly IAggregatesDomainEventsRequestStore _aggregatesDomainEventsStore; + + public DomainEventAccessor( + IDomainEventContext domainEventContext, + IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore + ) + { + _domainEventContext = domainEventContext; + _aggregatesDomainEventsStore = aggregatesDomainEventsStore; + } + + public IReadOnlyList UnCommittedDomainEvents + { + get + { + _ = _aggregatesDomainEventsStore.GetAllUncommittedEvents(); + + // Or + return _domainEventContext.GetAllUncommittedEvents(); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventPublisher.cs new file mode 100644 index 00000000..210e1598 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainEventPublisher.cs @@ -0,0 +1,224 @@ +using System.Collections.Immutable; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions; +using Microsoft.Extensions.Logging; + +namespace BuildingBlocks.Core.Domain.Events; + +public class DomainEventPublisher : IDomainEventPublisher +{ + private readonly IMessagePersistenceService _messagePersistenceService; + private readonly IDomainEventsAccessor _domainEventsAccessor; + private readonly IInternalEventBus _internalEventBus; + private readonly ILogger _logger; + private readonly IEnumerable? _integrationEventMappers; + private readonly IEnumerable? _domainNotificationEventMappers; + private readonly IEnumerable? _eventMappers; + private readonly IDomainNotificationEventPublisher _domainNotificationEventPublisher; + + public DomainEventPublisher( + IMessagePersistenceService messagePersistenceService, + IDomainNotificationEventPublisher domainNotificationEventPublisher, + IDomainEventsAccessor domainEventsAccessor, + IInternalEventBus internalEventBus, + ILogger logger, + IEnumerable? integrationEventMappers = null, + IEnumerable? domainNotificationEventMappers = null, + IEnumerable? eventMappers = null + ) + { + _messagePersistenceService = messagePersistenceService; + _domainEventsAccessor = domainEventsAccessor; + _internalEventBus = internalEventBus; + _logger = logger; + _integrationEventMappers = integrationEventMappers; + _domainNotificationEventMappers = domainNotificationEventMappers; + _eventMappers = eventMappers; + _domainNotificationEventPublisher = domainNotificationEventPublisher; + } + + public Task PublishAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + return PublishAsync(new[] { domainEvent }, cancellationToken); + } + + public async Task PublishAsync(IDomainEvent[] domainEvents, CancellationToken cancellationToken = default) + { + domainEvents.NotBeNull(); + + if (!domainEvents.Any()) + return; + + // https://github.com/dotnet-architecture/eShopOnContainers/issues/700#issuecomment-461807560 + // https://github.com/dotnet-architecture/eShopOnContainers/blob/e05a87658128106fef4e628ccb830bc89325d9da/src/Services/Ordering/Ordering.Infrastructure/OrderingContext.cs#L65 + // http://www.kamilgrzybek.com/design/how-to-publish-and-handle-domain-events/ + // http://www.kamilgrzybek.com/design/handling-domain-events-missing-part/ + // https://www.ledjonbehluli.com/posts/domain_to_integration_event/ + + // Dispatch our domain events before commit + var eventsToDispatch = domainEvents.ToList(); + + if (!eventsToDispatch.Any()) + { + eventsToDispatch = new List(_domainEventsAccessor.UnCommittedDomainEvents); + } + + await DispatchAsync(eventsToDispatch.ToArray(), cancellationToken); + + // Save wrapped integration and notification events to outbox for further processing after commit + var wrappedNotificationEvents = eventsToDispatch.GetWrappedDomainNotificationEvents().ToArray(); + await _domainNotificationEventPublisher.PublishAsync(wrappedNotificationEvents.ToArray(), cancellationToken); + + var wrappedIntegrationEvents = eventsToDispatch.GetWrappedIntegrationEvents().ToArray(); + foreach (var wrappedIntegrationEvent in wrappedIntegrationEvents) + { + await _messagePersistenceService.AddPublishMessageAsync( + new MessageEnvelope(wrappedIntegrationEvent, new Dictionary()), + cancellationToken + ); + } + + // Save event mapper events into outbox for further processing after commit + var integrationEvents = GetIntegrationEvents(eventsToDispatch); + if (integrationEvents.Any()) + { + foreach (var integrationEvent in integrationEvents) + { + await _messagePersistenceService.AddPublishMessageAsync( + new MessageEnvelope(integrationEvent, new Dictionary()), + cancellationToken + ); + } + } + + var notificationEvents = GetNotificationEvents(eventsToDispatch); + + if (notificationEvents.Any()) + { + foreach (var notification in notificationEvents) + { + await _messagePersistenceService.AddNotificationAsync(notification, cancellationToken); + } + } + } + + private IReadOnlyList GetNotificationEvents(IList eventsToDispatch) + { + List notificationEvents = new List(); + + if (_eventMappers is { } && _eventMappers.Any()) + { + foreach (var eventMapper in _eventMappers) + { + var items = eventMapper.MapToDomainNotificationEvents(eventsToDispatch.AsReadOnly())?.ToList(); + if (items is not null && items.Any()) + { + notificationEvents.AddRange(items.Where(x => x is not null)!); + } + } + } + else if (_domainNotificationEventMappers is { } && notificationEvents.Any()) + { + foreach (var notificationEventMapper in _domainNotificationEventMappers) + { + var items = notificationEventMapper + .MapToDomainNotificationEvents(eventsToDispatch.AsReadOnly()) + ?.ToList(); + if (items is not null && items.Any()) + { + notificationEvents.AddRange(items.Where(x => x is not null)!); + } + } + } + + return notificationEvents.ToImmutableList(); + } + + private IReadOnlyList GetIntegrationEvents(IList eventsToDispatch) + { + List integrationEvents = new List(); + + if (_eventMappers is not null && _eventMappers.Any()) + { + foreach (var eventMapper in _eventMappers) + { + var items = eventMapper.MapToIntegrationEvents(eventsToDispatch.AsReadOnly())?.ToList(); + if (items is not null && items.Any()) + { + integrationEvents.AddRange(items.Where(x => x is not null)!); + } + } + } + else if (_integrationEventMappers is { } && _integrationEventMappers.Any()) + { + foreach (var integrationEventMapper in _integrationEventMappers) + { + var items = integrationEventMapper.MapToIntegrationEvents(eventsToDispatch.AsReadOnly())?.ToList(); + if (items is not null && items.Any()) + { + integrationEvents.AddRange(items.Where(x => x is not null)!); + } + } + } + + return integrationEvents.ToImmutableList(); + } + + private async Task DispatchAsync(TEvent @event, CancellationToken cancellationToken = default) + where TEvent : IEvent + { + @event.NotBeNull(); + + if (@event is IIntegrationEvent integrationEvent) + { + await _internalEventBus.Publish(integrationEvent, cancellationToken); + + _logger.LogDebug( + "Dispatched integration notification event {IntegrationEventName} with payload {IntegrationEventContent}", + integrationEvent.GetType().FullName, + integrationEvent + ); + + return; + } + + if (@event is IDomainEvent domainEvent) + { + await _internalEventBus.Publish(domainEvent, cancellationToken); + + _logger.LogDebug( + "Dispatched domain event {DomainEventName} with payload {DomainEventContent}", + domainEvent.GetType().FullName, + domainEvent + ); + + return; + } + + if (@event is IDomainNotificationEvent notificationEvent) + { + await _internalEventBus.Publish(notificationEvent, cancellationToken); + + _logger.LogDebug( + "Dispatched domain notification event {DomainNotificationEventName} with payload {DomainNotificationEventContent}", + notificationEvent.GetType().FullName, + notificationEvent + ); + return; + } + + await _internalEventBus.Publish(@event, cancellationToken); + } + + private async Task DispatchAsync(TEvent[] events, CancellationToken cancellationToken = default) + where TEvent : IEvent + { + foreach (var @event in events) + { + await DispatchAsync(@event, cancellationToken); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainNotificationEventPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainNotificationEventPublisher.cs new file mode 100644 index 00000000..21b8ac11 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/DomainNotificationEventPublisher.cs @@ -0,0 +1,38 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions; + +namespace BuildingBlocks.Core.Domain.Events; + +public class DomainNotificationEventPublisher : IDomainNotificationEventPublisher +{ + private readonly IMessagePersistenceService _messagePersistenceService; + + public DomainNotificationEventPublisher(IMessagePersistenceService messagePersistenceService) + { + _messagePersistenceService = messagePersistenceService; + } + + public Task PublishAsync( + IDomainNotificationEvent domainNotificationEvent, + CancellationToken cancellationToken = default + ) + { + domainNotificationEvent.NotBeNull(); + + return _messagePersistenceService.AddNotificationAsync(domainNotificationEvent, cancellationToken); + } + + public async Task PublishAsync( + IDomainNotificationEvent[] domainNotificationEvents, + CancellationToken cancellationToken = default + ) + { + domainNotificationEvents.NotBeNull(); + + foreach (var domainNotificationEvent in domainNotificationEvents) + { + await _messagePersistenceService.AddNotificationAsync(domainNotificationEvent, cancellationToken); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Event.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Event.cs new file mode 100644 index 00000000..95c499ed --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Event.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Core.Types; + +namespace BuildingBlocks.Core.Domain.Events; + +public record Event : IEvent +{ + public Guid EventId { get; } = Guid.NewGuid(); + public long EventVersion => -1; + public DateTime OccurredOn { get; } = DateTime.Now; + public DateTimeOffset TimeStamp { get; } = DateTimeOffset.Now; + public string EventType => TypeMapper.GetFullTypeName(GetType()); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventHandlerDecorator.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventHandlerDecorator.cs new file mode 100644 index 00000000..97a3f847 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventHandlerDecorator.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Abstractions.Domain.Events; + +namespace BuildingBlocks.Core.Domain.Events; + +public class EventHandlerDecorator : IEventHandler + where TEvent : IEvent +{ + private readonly IEventHandler _eventHandler; + + public EventHandlerDecorator(IEventHandler eventHandler) + { + _eventHandler = eventHandler; + } + + public async Task Handle(TEvent notification, CancellationToken cancellationToken) + { + // TODO: Using Activity for tracing + + await _eventHandler.Handle(notification, cancellationToken); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventsExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventsExtensions.cs new file mode 100644 index 00000000..265229b2 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/EventsExtensions.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Reflection.Extensions; + +namespace BuildingBlocks.Core.Domain.Events; + +public static class EventsExtensions +{ + public static IEnumerable GetHandledIntegrationEventTypes(this Assembly[] assemblies) + { + var messageHandlerTypes = typeof(IIntegrationEventHandler<>) + .GetAllTypesImplementingOpenGenericInterface(assemblies) + .ToList(); + + var inheritsTypes = messageHandlerTypes + .SelectMany(x => x.GetInterfaces()) + .Where( + x => + x.GetInterfaces().Any(i => i.IsGenericType) + && x.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>) + ); + + foreach (var inheritsType in inheritsTypes) + { + var messageType = inheritsType.GetGenericArguments().First(); + if (messageType.IsAssignableTo(typeof(IIntegrationEvent))) + { + yield return messageType; + } + } + } + + public static IEnumerable GetHandledDomainNotificationEventTypes(this Assembly[] assemblies) + { + var messageHandlerTypes = typeof(IDomainNotificationEventHandler<>) + .GetAllTypesImplementingOpenGenericInterface(assemblies) + .ToList(); + + var inheritsTypes = messageHandlerTypes + .SelectMany(x => x.GetInterfaces()) + .Where( + x => + x.GetInterfaces().Any(i => i.IsGenericType) + && x.GetGenericTypeDefinition() == typeof(IDomainNotificationEventHandler<>) + ); + + foreach (var inheritsType in inheritsTypes) + { + var messageType = inheritsType.GetGenericArguments().First(); + if (messageType.IsAssignableTo(typeof(IDomainNotificationEvent))) + { + yield return messageType; + } + } + } + + public static IEnumerable GetHandledDomainEventTypes(this Assembly[] assemblies) + { + var messageHandlerTypes = typeof(IDomainEventHandler<>) + .GetAllTypesImplementingOpenGenericInterface(assemblies) + .ToList(); + + var inheritsTypes = messageHandlerTypes + .SelectMany(x => x.GetInterfaces()) + .Where( + x => + x.GetInterfaces().Any(i => i.IsGenericType) + && x.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>) + ); + + foreach (var inheritsType in inheritsTypes) + { + var messageType = inheritsType.GetGenericArguments().First(); + if (messageType.IsAssignableTo(typeof(IDomainEvent))) + { + yield return messageType; + } + } + } + + public static IEnumerable GetWrappedDomainNotificationEvents( + this IEnumerable domainEvents + ) + { + foreach ( + IDomainEvent domainEvent in domainEvents.Where( + x => typeof(IHaveNotificationEvent).IsAssignableFrom(x.GetType()) + ) + ) + { + Type genericType = typeof(DomainNotificationEventWrapper<>).MakeGenericType(domainEvent.GetType()); + + IDomainNotificationEvent? domainNotificationEvent = (IDomainNotificationEvent?) + Activator.CreateInstance(genericType, domainEvent); + + if (domainNotificationEvent is not null) + yield return domainNotificationEvent; + } + } + + public static IEnumerable GetWrappedIntegrationEvents( + this IEnumerable domainEvents + ) + { + foreach ( + IDomainEvent domainEvent in domainEvents.Where( + x => typeof(IHaveExternalEvent).IsAssignableFrom(x.GetType()) + ) + ) + { + Type genericType = typeof(IntegrationEventWrapper<>).MakeGenericType(domainEvent.GetType()); + + IIntegrationEvent? domainNotificationEvent = (IIntegrationEvent?) + Activator.CreateInstance(genericType, domainEvent); + + if (domainNotificationEvent is not null) + yield return domainNotificationEvent; + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEvent.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEvent.cs new file mode 100644 index 00000000..aed6f693 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEvent.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events.Internal; + +public record DomainEvent : Event, IDomainEvent +{ + public dynamic AggregateId { get; private set; } = default!; + public long AggregateSequenceNumber { get; private set; } + + public virtual IDomainEvent WithAggregate(dynamic aggregateId, long version) + { + AggregateId = aggregateId; + AggregateSequenceNumber = version; + + return this; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEventsInvoker.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEventsInvoker.cs new file mode 100644 index 00000000..1ea3c718 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainEventsInvoker.cs @@ -0,0 +1,40 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events.Internal; + +// /// +// /// Execute event handlers immediately +// /// Domain Events - Before Persistence +// /// Ref https://ardalis.com/immediate-domain-event-salvation-with-mediatr/ +// /// https://www.weeklydevtips.com/episodes/022 +// /// +// public static class DomainEventsInvoker +// { +// private static readonly Func _eventProcessorFunc = ServiceActivator +// .GetScope() +// .ServiceProvider.GetRequiredService; +// +// public static async Task RaiseDomainEventAsync( +// IDomainEvent[] domainEvents, +// CancellationToken cancellationToken = default +// ) +// { +// var eventProcessor = _eventProcessorFunc.Invoke(); +// foreach (var domainEvent in domainEvents) +// { +// await eventProcessor.DispatchAsync(domainEvent, cancellationToken: cancellationToken); +// } +// } +// +// public static Task RaiseDomainEventAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) +// { +// var eventProcessor = _eventProcessorFunc.Invoke(); +// return eventProcessor.DispatchAsync(domainEvent, cancellationToken: cancellationToken); +// } +// +// public static void RaiseDomainEvent(IDomainEvent domainEvent, CancellationToken cancellationToken = default) +// { +// var eventProcessor = _eventProcessorFunc.Invoke(); +// eventProcessor.DispatchAsync(domainEvent, cancellationToken: cancellationToken).GetAwaiter().GetResult(); +// } +// } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEvent.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEvent.cs new file mode 100644 index 00000000..bee5fce2 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEvent.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events.Internal; + +public abstract record DomainNotificationEvent : Event, IDomainNotificationEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEventWrapper.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEventWrapper.cs new file mode 100644 index 00000000..1acf23ba --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/DomainNotificationEventWrapper.cs @@ -0,0 +1,6 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Core.Domain.Events.Internal; + +public record DomainNotificationEventWrapper(TDomainEventType DomainEvent) : DomainNotificationEvent + where TDomainEventType : IDomainEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/IntegrationEventWrapper.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/IntegrationEventWrapper.cs new file mode 100644 index 00000000..0ffab380 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/IntegrationEventWrapper.cs @@ -0,0 +1,7 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Messaging; + +namespace BuildingBlocks.Core.Domain.Events.Internal; + +public record IntegrationEventWrapper : IntegrationEvent + where TDomainEventType : IDomainEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/NotificationEvent.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/NotificationEvent.cs new file mode 100644 index 00000000..9df890a1 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/Internal/NotificationEvent.cs @@ -0,0 +1,4 @@ +namespace BuildingBlocks.Core.Domain.Events.Internal; + +// Just for executing after transaction +public record NotificationEvent(dynamic Data) : DomainNotificationEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/InternalEventBus.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/InternalEventBus.cs new file mode 100644 index 00000000..e8585ba7 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Events/InternalEventBus.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using System.Reflection; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using MediatR; +using Polly; + +namespace BuildingBlocks.Core.Domain.Events; + +public class InternalEventBus : IInternalEventBus +{ + private readonly IMediator _mediator; + private static readonly ConcurrentDictionary _publishMethods = new(); + private readonly AsyncPolicy _policy; + + public InternalEventBus(IMediator mediator, AsyncPolicy policy) + { + _mediator = mediator; + _policy = policy; + } + + public async Task Publish(IEvent @event, CancellationToken ct) + { + var policy = Policy.Handle().RetryAsync(2); + + await policy.ExecuteAsync(c => _mediator.Publish(@event, c), ct); + } + + public async Task Publish(MessageEnvelope eventEnvelope, CancellationToken ct) + where T : class, IMessage + { + await _policy.ExecuteAsync(c => _mediator.Publish(eventEnvelope.Message, c), ct); + } + + public async Task Publish(IMessage @event, CancellationToken ct) + { + await _policy.ExecuteAsync(c => _mediator.Publish(@event, c), ct); + } + + public Task Publish(IStreamEvent eventEnvelope, CancellationToken ct) + { + // calling generic `Publish` in `InternalEventBus` class + var genericPublishMethod = _publishMethods.GetOrAdd( + eventEnvelope.Data.GetType(), + eventType => + typeof(InternalEventBus) + .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Single(m => m.Name == nameof(Publish) && m.GetGenericArguments().Any()) + .MakeGenericMethod(eventType) + ); + + return (Task)genericPublishMethod.Invoke(this, new object[] { eventEnvelope, ct })!; + } + + public async Task Publish(IStreamEvent eventEnvelope, CancellationToken ct) + where T : IDomainEvent + { + await _policy.ExecuteAsync(c => _mediator.Publish(eventEnvelope.Data, c), ct); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/DomainException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/DomainException.cs index 736363e9..5011158f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/DomainException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/DomainException.cs @@ -1,16 +1,34 @@ -using System.Net; using BuildingBlocks.Core.Exception.Types; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Domain.Exceptions; +// https://www.kamilgrzybek.com/blog/posts/domain-model-validation + /// /// Exception type for domain exceptions. /// public class DomainException : CustomException { - public DomainException(string message, HttpStatusCode statusCode = HttpStatusCode.BadRequest) - : base(message) + private readonly Type? _brokenRuleType; + + public DomainException(string message, int statusCode = StatusCodes.Status409Conflict) + : base(message, statusCode) { } + + public DomainException(Type businessRuleType, string message, int statusCode = StatusCodes.Status409Conflict) + : base(message, statusCode) { - StatusCode = statusCode; + _brokenRuleType = businessRuleType; + } + + // Will use in the problem detail `title` field. + public override string ToString() + { + if (_brokenRuleType is not null) + { + return $"{GetType().FullName}:{_brokenRuleType.FullName}"; + } + + return $"{GetType().FullName}"; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidAmountException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidAmountException.cs new file mode 100644 index 00000000..a0a803e6 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidAmountException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Core.Domain.Exceptions; + +public class InvalidAmountException : BadRequestException +{ + public decimal Amount { get; } + + public InvalidAmountException(decimal amount) + : base($"Amount: '{amount}' is invalid.") + { + Amount = amount; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidCurrencyException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidCurrencyException.cs new file mode 100644 index 00000000..ff0ceb4e --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidCurrencyException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Core.Domain.Exceptions; + +public class InvalidCurrencyException : BadRequestException +{ + public string Currency { get; } + + public InvalidCurrencyException(string currency) + : base($"Currency: '{currency}' is invalid.") + { + Currency = currency; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidDateException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidDateException.cs new file mode 100644 index 00000000..1900a419 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidDateException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Core.Domain.Exceptions; + +public class InvalidDateException : BadRequestException +{ + public DateTime Date { get; } + + public InvalidDateException(DateTime date) + : base($"Date: '{date}' is invalid.") + { + Date = date; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidEmailException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidEmailException.cs new file mode 100644 index 00000000..f467a414 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidEmailException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Core.Domain.Exceptions; + +public class InvalidEmailException : BadRequestException +{ + public string Email { get; } + + public InvalidEmailException(string email) + : base($"Email: '{email}' is invalid.") + { + Email = email; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidPhoneNumberException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidPhoneNumberException.cs new file mode 100644 index 00000000..e9d28e09 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Exceptions/InvalidPhoneNumberException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Core.Domain.Exceptions; + +public class InvalidPhoneNumberException : BadRequestException +{ + public string PhoneNumber { get; } + + public InvalidPhoneNumberException(string phoneNumber) + : base($"PhoneNumber: '{phoneNumber}' is invalid.") + { + PhoneNumber = phoneNumber; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs index 421be1a6..4a8cc5ff 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs @@ -1,6 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record Address { // EF @@ -13,18 +18,30 @@ private Address() { } public static Address Empty => new(); - public static Address Of(string country, string city, string detail, PostalCode postalCode) + public static Address Of( + [NotNull] string? country, + [NotNull] string? city, + [NotNull] string? detail, + [NotNull] PostalCode? postalCode + ) { var address = new Address { - Country = country, - City = city, - Detail = detail, - PostalCode = postalCode + Country = country.NotBeNullOrWhiteSpace(), + City = city.NotBeNullOrWhiteSpace(), + Detail = detail.NotBeNullOrWhiteSpace(), + PostalCode = postalCode.NotBeNull() }; return address; } + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out string country, out string city, out string detail, out PostalCode postalCode) => + (country, city, detail, postalCode) = (Country, City, Detail, PostalCode); } public record PostalCode @@ -36,7 +53,7 @@ public PostalCode() { } public string Value { get; init; } = default!; // validations should be placed here instead of constructor - public static PostalCode Of(string postalCode) => new() { Value = postalCode }; + public static PostalCode Of(string? postalCode) => new() { Value = postalCode.NotBeNullOrWhiteSpace() }; public static implicit operator string(PostalCode postalCode) => postalCode.Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs index 007ad4e2..26ddfff0 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs @@ -1,9 +1,13 @@ -using BuildingBlocks.Core.Exception.Types; +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Domain.Exceptions; +using BuildingBlocks.Core.Extensions; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record Amount { // EF @@ -18,10 +22,19 @@ private Amount(decimal value) public decimal Value { get; private set; } public static Amount Zero => Of(0); + public static Amount Of([NotNull] decimal? value) + { + value.NotBeNull(); + + return Of(value.Value); + } + public static Amount Of(decimal value) { + value.NotBeNegativeOrZero(); + // validations should be placed here instead of constructor - if (value is < 0 or > 1000000) + if (value > 1000000) { throw new InvalidAmountException(value); } @@ -42,4 +55,10 @@ public static Amount Of(decimal value) public static Amount operator +(Amount a, Amount b) => new(a.Value + b.Value); public static Amount operator -(Amount a, Amount b) => new(a.Value - b.Value); + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out decimal value) => value = Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs index 8eca5fa3..4fc0380c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs @@ -1,8 +1,12 @@ +using System.Diagnostics.CodeAnalysis; using BuildingBlocks.Core.Domain.Exceptions; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record BirthDate { // EF Core @@ -10,6 +14,13 @@ private BirthDate() { } public DateTime Value { get; private set; } + public static BirthDate Of([NotNull] DateTime? value) + { + value.NotBeNull(); + + return Of(value.Value); + } + public static BirthDate Of(DateTime value) { // validations should be placed here instead of constructor @@ -31,4 +42,10 @@ public static BirthDate Of(DateTime value) } public static implicit operator DateTime(BirthDate value) => value.Value; + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out DateTime value) => value = Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs index fa44801e..55bb3ce4 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs @@ -1,11 +1,12 @@ -using Ardalis.GuardClauses; -using BuildingBlocks.Core.Domain.Exceptions; -using BuildingBlocks.Core.Exception; +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record Currency { // EF @@ -19,12 +20,19 @@ private Currency(string value) // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties public string Value { get; private set; } = default!; - public static Currency Of(string value) + public static Currency Of([NotNull] string? value) { // validations should be placed here instead of constructor - Guard.Against.InvalidCurrency(value, new DomainException($"Currency {value} is invalid.")); + value.NotBeNullOrWhiteSpace(); + value.NotBeInvalidCurrency(); return new Currency(value); } public static implicit operator string(Currency value) => value.Value; + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out string value) => value = Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs index 2eb1b6c9..0b33df04 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs @@ -1,9 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; using FluentValidation; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record Email { // EF @@ -17,21 +21,28 @@ private Email(string value) // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties public string Value { get; private set; } - public static Email Of(string value) + public static Email Of([NotNull] string? value) { // validations should be placed here instead of constructor - new EmailValidator().ValidateAndThrow(value); + new EmailValidator()!.ValidateAndThrow(value); + return new Email(value); } public static implicit operator string(Email value) => value.Value; + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out string value) => value = Value; + private sealed class EmailValidator : AbstractValidator { public EmailValidator() { - RuleFor(email => email).NotEmpty(); RuleFor(email => email).EmailAddress(); + RuleFor(email => email).NotEmpty(); } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs new file mode 100644 index 00000000..3132bc08 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local +namespace BuildingBlocks.Core.Domain.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record MobileNumber +{ + // EF + private MobileNumber(string value) + { + Value = value; + } + + // Note: in entities with none default constructor, for setting constructor parameter, we need a private set property + // when we didn't define this property in fluent configuration mapping (if so we can remove private set) , because for getting mapping list of properties to set + // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties + public string Value { get; private set; } = default!; + + public static MobileNumber Of([NotNull] string? value) + { + value.NotBeNull(); + value.NotBeInvalidMobileNumber(); + return new MobileNumber(value); + } + + public static implicit operator string(MobileNumber phoneNumber) => phoneNumber.Value; + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Money.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Money.cs index a90ec01b..6409bd9c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Money.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Money.cs @@ -1,8 +1,10 @@ -using Ardalis.GuardClauses; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record Money { // EF @@ -11,17 +13,25 @@ private Money() { } public decimal Value { get; private set; } public string Currency { get; private set; } = default!; - public static Money Of(decimal value, string currency) + public static Money Of(decimal? value, string? currency) { // validations should be placed here instead of constructor - Guard.Against.NegativeOrZero(value, nameof(value)); - Guard.Against.NullOrWhiteSpace(currency, nameof(currency)); + value.NotBeNull(); + value.NotBeNegativeOrZero(); + currency.NotBeNullOrWhiteSpace(); + currency.NotBeInvalidCurrency(); - return new Money { Currency = currency, Value = value }; + return new Money { Currency = currency, Value = value.Value }; } public static Money operator *(int left, Money right) { return Of(right.Value * left, right.Currency); } + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out decimal value, out string currency) => (value, currency) = (Value, Currency); } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs index 80ddcd66..c28ac4ae 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs @@ -1,11 +1,12 @@ -using Ardalis.GuardClauses; -using BuildingBlocks.Core.Domain.Exceptions; -using BuildingBlocks.Core.Exception; +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local namespace BuildingBlocks.Core.Domain.ValueObjects; // https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ public record PhoneNumber { // EF @@ -19,12 +20,18 @@ private PhoneNumber(string value) // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties public string Value { get; private set; } = default!; - public static PhoneNumber Of(string value) + public static PhoneNumber Of([NotNull] string? value) { - // validations should be placed here instead of constructor - Guard.Against.InvalidPhoneNumber(value, new DomainException($"Phone number {value} is invalid.")); + value.NotBeNull(); + value.NotBeInvalidPhoneNumber(); return new PhoneNumber(value); } public static implicit operator string(PhoneNumber phoneNumber) => phoneNumber.Value; + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out string value) => value = Value; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/AppException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/AppException.cs index e61071bb..5f98e2d5 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/AppException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/AppException.cs @@ -1,12 +1,13 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class AppException : CustomException { - public AppException(string message, HttpStatusCode statusCode = HttpStatusCode.BadRequest) - : base(message) - { - StatusCode = statusCode; - } + public AppException( + string message, + int statusCode = StatusCodes.Status400BadRequest, + System.Exception? innerException = null + ) + : base(message, statusCode, innerException) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/BadRequestException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/BadRequestException.cs index a601d18e..8d8026c7 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/BadRequestException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/BadRequestException.cs @@ -1,12 +1,9 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class BadRequestException : CustomException { - public BadRequestException(string message) - : base(message) - { - StatusCode = HttpStatusCode.NotFound; - } + public BadRequestException(string message, System.Exception? innerException = null, params string[] errors) + : base(message, StatusCodes.Status400BadRequest, innerException, errors) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ConflictException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ConflictException.cs index f1373f82..12151fa9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ConflictException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ConflictException.cs @@ -1,12 +1,26 @@ -using System.Net; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Domain.Exceptions; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class ConflictException : CustomException { - public ConflictException(string message) - : base(message) - { - StatusCode = HttpStatusCode.Conflict; - } + public ConflictException(string message, System.Exception? innerException = null) + : base(message, StatusCodes.Status409Conflict, innerException) { } +} + +public class ConflictAppException : AppException +{ + public ConflictAppException(string message, System.Exception? innerException = null) + : base(message, StatusCodes.Status409Conflict, innerException) { } +} + +public class ConflictDomainException : DomainException +{ + public ConflictDomainException(string message) + : base(message, StatusCodes.Status409Conflict) { } + + public ConflictDomainException(Type businessRuleType, string message) + : base(businessRuleType, message, StatusCodes.Status409Conflict) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/CustomException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/CustomException.cs index 7d565c40..b2104132 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/CustomException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/CustomException.cs @@ -1,15 +1,16 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class CustomException : System.Exception { - public CustomException( + protected CustomException( string message, - HttpStatusCode statusCode = HttpStatusCode.InternalServerError, + int statusCode = StatusCodes.Status500InternalServerError, + System.Exception? innerException = null, params string[] errors ) - : base(message) + : base(message, innerException) { ErrorMessages = errors; StatusCode = statusCode; @@ -17,5 +18,11 @@ params string[] errors public IEnumerable ErrorMessages { get; protected set; } - public HttpStatusCode StatusCode { get; protected set; } + public int StatusCode { get; protected set; } + + // Will use in the problem detail `title` field. + public override string ToString() + { + return GetType().FullName ?? GetType().Name; + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ForbiddenException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ForbiddenException.cs index 6263d6d9..eee7001e 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ForbiddenException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ForbiddenException.cs @@ -1,9 +1,9 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class ForbiddenException : IdentityException { - public ForbiddenException(string message) - : base(message, statusCode: HttpStatusCode.Forbidden) { } + public ForbiddenException(string message, System.Exception? innerException = null) + : base(message, statusCode: StatusCodes.Status403Forbidden, innerException) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/HttpResponseException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/HttpResponseException.cs index 39aed52b..d9f49726 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/HttpResponseException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/HttpResponseException.cs @@ -1,10 +1,29 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws public class HttpResponseException : CustomException { - public HttpResponseException(string message, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) - : base(message, statusCode) { } + public string? ResponseContent { get; } + + public IReadOnlyDictionary>? Headers { get; } + + public HttpResponseException( + string responseContent, + int statusCode = StatusCodes.Status500InternalServerError, + IReadOnlyDictionary>? headers = null, + System.Exception? inner = null + ) + : base(responseContent, statusCode, inner) + { + StatusCode = statusCode; + ResponseContent = responseContent; + Headers = headers; + } + + public override string ToString() + { + return $"HTTP Response: \n\n{ResponseContent}\n\n{base.ToString()}"; + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/IdentityException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/IdentityException.cs index 11f04b44..fa2b519e 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/IdentityException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/IdentityException.cs @@ -1,4 +1,4 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; @@ -6,8 +6,9 @@ public class IdentityException : CustomException { public IdentityException( string message, - HttpStatusCode statusCode = HttpStatusCode.BadRequest, + int statusCode = StatusCodes.Status400BadRequest, + System.Exception? innerException = null, params string[] errors ) - : base(message, statusCode, errors) { } + : base(message, statusCode, innerException, errors) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/NotFoundException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/NotFoundException.cs index e85e614d..3223901a 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/NotFoundException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/NotFoundException.cs @@ -1,12 +1,25 @@ -using System.Net; +using BuildingBlocks.Core.Domain.Exceptions; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class NotFoundException : CustomException { - public NotFoundException(string message) - : base(message) - { - StatusCode = HttpStatusCode.NotFound; - } + public NotFoundException(string message, System.Exception? innerException = null) + : base(message, StatusCodes.Status404NotFound, innerException) { } +} + +public class NotFoundAppException : AppException +{ + public NotFoundAppException(string message, System.Exception? innerException = null) + : base(message, StatusCodes.Status404NotFound, innerException) { } +} + +public class NotFoundDomainException : DomainException +{ + public NotFoundDomainException(string message) + : base(message, StatusCodes.Status404NotFound) { } + + public NotFoundDomainException(Type businessRuleType, string message) + : base(businessRuleType, message, StatusCodes.Status404NotFound) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/UnAuthorizedException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/UnAuthorizedException.cs index 47f24d68..98e7a193 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/UnAuthorizedException.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/UnAuthorizedException.cs @@ -1,9 +1,9 @@ -using System.Net; +using Microsoft.AspNetCore.Http; namespace BuildingBlocks.Core.Exception.Types; public class UnAuthorizedException : IdentityException { - public UnAuthorizedException(string message) - : base(message, HttpStatusCode.Unauthorized) { } + public UnAuthorizedException(string message, System.Exception? innerException = null) + : base(message, StatusCodes.Status401Unauthorized, innerException) { } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ValidationException.cs b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ValidationException.cs new file mode 100644 index 00000000..a68b9935 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Exception/Types/ValidationException.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Core.Exception.Types; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Validation; + +public class ValidationException : BadRequestException +{ + public ValidationException(string message, Exception? innerException = null, params string[] errors) + : base(message, innerException, errors) { } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs new file mode 100644 index 00000000..8a5d32b3 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; + +namespace BuildingBlocks.Core.Extensions; + +/// +/// Static helper class for . +/// +public static class ConfigurationExtensions +{ + /// + /// Attempts to bind the instance to configuration section values. + /// + /// The given bind model. + /// The configuration instance to bind. + /// The configuration section + /// The new instance of . + public static TOptions BindOptions(this IConfiguration configuration, string section) + where TOptions : new() + { + // note: with using Get<>() if there is no configuration in appsettings it just returns default value (null) for the configuration type + // but if we use Bind() we can pass a instantiated type with its default value (for example in its property initialization) to bind method for binding configurations from appsettings + // https://www.twilio.com/blog/provide-default-configuration-to-dotnet-applications + var options = new TOptions(); + + var optionsSection = configuration.GetSection(section); + optionsSection.Bind(options); + + return options; + } + + /// + /// Attempts to bind the instance to configuration section values. + /// + /// The given bind model. + /// The configuration instance to bind. + /// The new instance of . + public static TOptions BindOptions(this IConfiguration configuration) + where TOptions : new() + { + return BindOptions(configuration, typeof(TOptions).Name); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/HostApplicationLifetimeExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/HostApplicationLifetimeExtensions.cs new file mode 100644 index 00000000..0b1c25f4 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/HostApplicationLifetimeExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Hosting; + +namespace BuildingBlocks.Core.Extensions; + +public static class HostApplicationLifetimeExtensions +{ + // ref: https://andrewlock.net/finding-the-urls-of-an-aspnetcore-app-from-a-hosted-service-in-dotnet-6/ + public static async Task WaitForAppStartup( + this IHostApplicationLifetime lifetime, + CancellationToken stoppingToken + ) + { + var startedSource = new TaskCompletionSource(); + var cancelledSource = new TaskCompletionSource(); + + await using var reg1 = lifetime.ApplicationStarted.Register(() => startedSource.SetResult()); + await using var reg2 = stoppingToken.Register(() => cancelledSource.SetResult()); + + Task completedTask = await Task.WhenAny(startedSource.Task, cancelledSource.Task).ConfigureAwait(false); + + // If the completed tasks was the "app started" task, return true, otherwise false + return completedTask == startedSource.Task; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/QueryableExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/QueryableExtensions.cs new file mode 100644 index 00000000..c4dec632 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/QueryableExtensions.cs @@ -0,0 +1,158 @@ +using System.Linq.Expressions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Paging; +using Sieve.Models; +using Sieve.Services; + +namespace BuildingBlocks.Core.Extensions; + +// we should not operation related to Ef or Mongo here and we should design as general with IQueryable to work with any providers +public static class QueryableExtensions +{ + public static async Task> ApplyPagingAsync( + this IQueryable queryable, + IPageRequest pageRequest, + ISieveProcessor sieveProcessor, + CancellationToken cancellationToken + ) + where TEntity : class + { + var sieveModel = new SieveModel + { + PageSize = pageRequest.PageSize, + Page = pageRequest.PageNumber, + Sorts = pageRequest.SortOrder, + Filters = pageRequest.Filters + }; + + // https://github.com/Biarity/Sieve/issues/34#issuecomment-403817573 + var result = sieveProcessor.Apply(sieveModel, queryable, applyPagination: false); + var total = result.Count(); + result = sieveProcessor.Apply(sieveModel, queryable, applyFiltering: false, applySorting: false); // Only + + var items = await result.ToAsyncEnumerable().ToListAsync(cancellationToken: cancellationToken); + + return PageList.Create(items.AsReadOnly(), pageRequest.PageNumber, pageRequest.PageSize, total); + } + + public static async Task> ApplyPagingAsync( + this IQueryable queryable, + IPageRequest pageRequest, + IConfigurationProvider configurationProvider, + ISieveProcessor sieveProcessor, + CancellationToken cancellationToken + ) + where TEntity : class + where TResult : class + { + var sieveModel = new SieveModel + { + PageSize = pageRequest.PageSize, + Page = pageRequest.PageNumber, + Sorts = pageRequest.SortOrder, + Filters = pageRequest.Filters + }; + + // https://github.com/Biarity/Sieve/issues/34#issuecomment-403817573 + var result = sieveProcessor.Apply(sieveModel, queryable, applyPagination: false); + var total = result.Count(); + result = sieveProcessor.Apply(sieveModel, queryable, applyFiltering: false, applySorting: false); // Only applies pagination + + var items = await result + .ProjectTo(configurationProvider) + .ToAsyncEnumerable() + .ToListAsync(cancellationToken: cancellationToken); + + return PageList.Create(items.AsReadOnly(), pageRequest.PageNumber, pageRequest.PageSize, total); + } + + public static async Task> ApplyPagingAsync( + this IQueryable queryable, + IPageRequest pageRequest, + ISieveProcessor sieveProcessor, + Func map, + CancellationToken cancellationToken + ) + where TEntity : class + where TResult : class + { + var sieveModel = new SieveModel + { + PageSize = pageRequest.PageSize, + Page = pageRequest.PageNumber, + Sorts = pageRequest.SortOrder, + Filters = pageRequest.Filters + }; + + // https://github.com/Biarity/Sieve/issues/34#issuecomment-403817573 + var result = sieveProcessor.Apply(sieveModel, queryable, applyPagination: false); + var total = result.Count(); + result = sieveProcessor.Apply(sieveModel, queryable, applyFiltering: false, applySorting: false); // Only applies pagination + + var items = await result + .Select(x => map(x)) + .ToAsyncEnumerable() + .ToListAsync(cancellationToken: cancellationToken); + + return PageList.Create(items.AsReadOnly(), pageRequest.PageNumber, pageRequest.PageSize, total); + } + + public static async Task> ApplyPagingAsync( + this IQueryable collection, + IPageRequest pageRequest, + ISieveProcessor sieveProcessor, + IConfigurationProvider configuration, + Expression>? predicate = null, + Expression>? sortExpression = null, + CancellationToken cancellationToken = default + ) + where TEntity : class + where TResult : class + { + IQueryable query = collection; + if (predicate is not null) + { + query = query.Where(predicate); + } + + if (sortExpression is not null) + { + query = query.OrderByDescending(sortExpression); + } + + return await query.ApplyPagingAsync( + pageRequest, + configuration, + sieveProcessor, + cancellationToken + ); + } + + public static async Task> ApplyPagingAsync( + this IQueryable collection, + IPageRequest pageRequest, + ISieveProcessor sieveProcessor, + Expression>? predicate = null, + Expression>? sortExpression = null, + CancellationToken cancellationToken = default + ) + where TEntity : class + { + IQueryable query = collection; + if (predicate is not null) + { + query = query.Where(predicate); + } + + if (sortExpression is not null) + { + query = query.OrderByDescending(sortExpression); + } + + return await query.ApplyPagingAsync(pageRequest, sieveProcessor, cancellationToken); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Dependency.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Dependency.cs new file mode 100644 index 00000000..4af3bedb --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Dependency.cs @@ -0,0 +1,317 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Core.Extensions.ServiceCollection; + +public static partial class ServiceCollectionExtensions +{ + public static IServiceCollection Replace( + this IServiceCollection services, + ServiceLifetime lifetime + ) + { + return services.Replace(new ServiceDescriptor(typeof(TService), typeof(TImplementation), lifetime)); + } + + public static IServiceCollection ReplaceScoped(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + return services.Replace(ServiceDescriptor.Scoped()); + } + + public static IServiceCollection ReplaceScoped( + this IServiceCollection services, + Func implementationFactory + ) + where TService : class + { + return services.Replace(ServiceDescriptor.Scoped(implementationFactory)); + } + + public static IServiceCollection ReplaceTransient(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + return services.Replace(ServiceDescriptor.Transient()); + } + + public static IServiceCollection ReplaceTransient( + this IServiceCollection services, + Func implementationFactory + ) + where TService : class + { + return services.Replace(ServiceDescriptor.Transient(implementationFactory)); + } + + public static IServiceCollection ReplaceSingleton(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + return services.Replace(ServiceDescriptor.Singleton()); + } + + public static IServiceCollection ReplaceSingleton( + this IServiceCollection services, + Func implementationFactory + ) + where TService : class + { + return services.Replace(ServiceDescriptor.Singleton(implementationFactory)); + } + + /// + /// Adds a new transient registration to the service collection only when no existing registration of the same service type and implementation type exists. + /// In contrast to TryTryAddTransient, which only checks the service type. + /// + /// + /// + /// + public static void TryTryAddTransientExact( + this IServiceCollection services, + Type serviceType, + Type implementationType + ) + { + if (services.Any(reg => reg.ServiceType == serviceType && reg.ImplementationType == implementationType)) + { + return; + } + + services.TryAddTransient(serviceType, implementationType); + } + + /// + /// Adds a new scope registration to the service collection only when no existing registration of the same service type and implementation type exists. + /// In contrast to TryAddScope, which only checks the service type. + /// + /// + /// + /// + public static void TryAddScopeExact(this IServiceCollection services, Type serviceType, Type implementationType) + { + if (services.Any(reg => reg.ServiceType == serviceType && reg.ImplementationType == implementationType)) + { + return; + } + + services.TryAddScoped(serviceType, implementationType); + } + + public static IServiceCollection Add( + this IServiceCollection services, + Func implementationFactory, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + where TService : class + where TImplementation : class, TService + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + services.TryAddSingleton(implementationFactory); + return services; + + case ServiceLifetime.Scoped: + services.TryAddScoped(implementationFactory); + return services; + + case ServiceLifetime.Transient: + services.TryAddTransient(implementationFactory); + return services; + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + Func implementationFactory, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + where TService : class + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + services.TryAddSingleton(implementationFactory); + return services; + + case ServiceLifetime.Scoped: + services.TryAddScoped(implementationFactory); + return services; + + case ServiceLifetime.Transient: + services.TryAddTransient(implementationFactory); + return services; + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + where TService : class + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + services.TryAddSingleton(); + return services; + + case ServiceLifetime.Scoped: + services.TryAddScoped(); + return services; + + case ServiceLifetime.Transient: + services.TryAddTransient(); + return services; + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + Type serviceType, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + services.TryAddSingleton(serviceType); + return services; + + case ServiceLifetime.Scoped: + services.TryAddScoped(serviceType); + return services; + + case ServiceLifetime.Transient: + services.TryAddTransient(serviceType); + return services; + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + where TService : class + where TImplementation : class, TService + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + return services.AddSingleton(); + + case ServiceLifetime.Scoped: + return services.AddScoped(); + + case ServiceLifetime.Transient: + return services.AddTransient(); + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + Type serviceType, + Func implementationFactory, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + return services.AddSingleton(serviceType, implementationFactory); + + case ServiceLifetime.Scoped: + return services.AddScoped(serviceType, implementationFactory); + + case ServiceLifetime.Transient: + return services.AddTransient(serviceType, implementationFactory); + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + public static IServiceCollection Add( + this IServiceCollection services, + Type serviceType, + Type implementationType, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient + ) + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + return services.AddSingleton(serviceType, implementationType); + + case ServiceLifetime.Scoped: + return services.AddScoped(serviceType, implementationType); + + case ServiceLifetime.Transient: + return services.AddTransient(serviceType, implementationType); + + default: + throw new ArgumentNullException(nameof(serviceLifetime)); + } + } + + // https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + // https://steven-giesel.com/blogPost/ce948083-974a-4c16-877f-246b8909fa6d + // https://www.stevejgordon.co.uk/aspnet-core-dependency-injection-what-is-the-iserviceprovider-and-how-is-it-built + // https://www.youtube.com/watch?v=8JkHgymp2R4 + + /// + /// Validate container dependencies. + /// + /// + /// + /// + public static void ValidateDependencies( + this IServiceProvider rootServiceProvider, + IServiceCollection services, + params Assembly[] assembliesToScan + ) + { + var scanAssemblies = assembliesToScan.Any() ? assembliesToScan : new[] { Assembly.GetExecutingAssembly(), }; + var exceptions = new List(); + + // for resolving scoped based dependencies without errors + using var scope = rootServiceProvider.CreateScope(); + var sp = scope.ServiceProvider; + + foreach (var serviceDescriptor in services) + { + try + { + var serviceType = serviceDescriptor.ServiceType; + if (scanAssemblies.Contains(serviceType.Assembly)) + sp.GetRequiredService(serviceType); + } + catch (System.Exception e) + { + exceptions.Add($"Unable to resolve '{serviceDescriptor.ServiceType.FullName}', detail: {e.Message}"); + } + } + + if (exceptions.Any()) + { + throw new System.Exception(string.Join("\n", exceptions)); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Options.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Options.cs new file mode 100644 index 00000000..3af69853 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/ServiceCollectionExtensions.Options.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace BuildingBlocks.Core.Extensions.ServiceCollection; + +public static partial class ServiceCollectionExtensions +{ + public static IServiceCollection AddConfigurationOptions( + this IServiceCollection services, + Action configuration + ) + where T : class + { + var key = typeof(T).Name; + + return services.AddConfigurationOptions(key, configuration); + } + + public static IServiceCollection AddConfigurationOptions( + this IServiceCollection services, + string key, + Action configuration + ) + where T : class + { + services.AddOptions().BindConfiguration(key).Configure(configuration); + + services.TryAddSingleton(x => x.GetRequiredService>().Value); + + return services; + } + + public static IServiceCollection AddValidationOptions( + this IServiceCollection services, + Action? configurator = null + ) + where T : class + { + var key = typeof(T).Name; + + return AddValidatedOptions(services, key, RequiredConfigurationValidator.Validate, configurator); + } + + public static IServiceCollection AddValidationOptions( + this IServiceCollection services, + string key, + Action? configurator = null + ) + where T : class + { + return AddValidatedOptions(services, key, RequiredConfigurationValidator.Validate, configurator); + } + + public static IServiceCollection AddConfigurationOptions(this IServiceCollection services) + where T : class + { + return services.AddConfigurationOptions(typeof(T).Name); + } + + public static IServiceCollection AddConfigurationOptions(this IServiceCollection services, string key) + where T : class + { + services.AddOptions().BindConfiguration(key); + + services.TryAddSingleton(x => x.GetRequiredService>().Value); + + return services; + } + + public static IServiceCollection AddValidatedOptions(this IServiceCollection services) + where T : class + { + return AddValidatedOptions(services, typeof(T).Name, RequiredConfigurationValidator.Validate); + } + + public static IServiceCollection AddValidatedOptions(this IServiceCollection services, string key) + where T : class + { + return AddValidatedOptions(services, key, RequiredConfigurationValidator.Validate); + } + + public static IServiceCollection AddValidatedOptions( + this IServiceCollection services, + string key, + Func validator, + Action? configurator = null + ) + where T : class + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options + // https://thecodeblogger.com/2021/04/21/options-pattern-in-net-ioptions-ioptionssnapshot-ioptionsmonitor/ + // https://code-maze.com/aspnet-configuration-options/ + // https://code-maze.com/aspnet-configuration-options-validation/ + // https://dotnetdocs.ir/Post/42/difference-between-ioptions-ioptionssnapshot-and-ioptionsmonitor + // https://andrewlock.net/adding-validation-to-strongly-typed-configuration-objects-in-dotnet-6/ + var configuration = services.AddOptions().BindConfiguration(key); + + if (configurator is not null) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/#configure-options-with-a-delegate + configuration.Configure(configurator); + } + + configuration.Validate(validator); + + // IOptions itself registered as singleton + services.TryAddSingleton(x => x.GetRequiredService>().Value); + + return services; + } +} + +public static class RequiredConfigurationValidator +{ + public static bool Validate(T arg) + where T : class + { + var requiredProperties = typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(x => Attribute.IsDefined(x, typeof(RequiredMemberAttribute))); + + foreach (var requiredProperty in requiredProperties) + { + var propertyValue = requiredProperty.GetValue(arg); + if (propertyValue is null) + { + throw new System.Exception($"Required property '{requiredProperty.Name}' was null"); + } + } + + return true; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs new file mode 100644 index 00000000..39a85299 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs @@ -0,0 +1,313 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Validation; + +namespace BuildingBlocks.Core.Extensions; + +// https://dev.to/lambdasharp/c-asserting-a-value-is-not-null-in-null-aware-code-f8m +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/nullable-analysis +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/caller-information +public static class ValidationExtensions +{ + private static readonly HashSet _allowedCurrency = new() { "USD", "EUR", }; + + public static T NotBeNull( + [NotNull] this T? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument; + } + + public static T NotBeNull([NotNull] this T? argument, System.Exception exception) + { + if (argument == null) + { + throw exception; + } + + return argument; + } + + public static string NotBeEmpty( + this string argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument.Length == 0) + { + throw new ValidationException($"{argumentName} cannot be null or empty."); + } + + return argument; + } + + public static string NotBeEmptyOrNull( + [NotNull] this string? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (string.IsNullOrEmpty(argument)) + { + throw new ValidationException($"{argumentName} cannot be null or empty."); + } + + return argument; + } + + public static string NotBeNullOrWhiteSpace( + [NotNull] this string? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (string.IsNullOrWhiteSpace(argument)) + { + throw new ValidationException($"{argumentName} cannot be null or white space."); + } + + return argument; + } + + public static Guid NotBeEmpty( + this Guid argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == Guid.Empty) + { + throw new ValidationException($"{argumentName} cannot be empty."); + } + + return argument; + } + + public static Guid NotBeEmpty( + [NotNull] this Guid? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument.Value.NotBeEmpty(); + } + + public static int NotBeNegativeOrZero( + this int argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == 0) + { + throw new ValidationException($"{argumentName} cannot be zero."); + } + + return argument; + } + + public static long NotBeNegativeOrZero( + [NotNull] this long? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument.Value.NotBeNegativeOrZero(); + } + + public static long NotBeNegativeOrZero( + this long argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == 0) + { + throw new ValidationException($"{argumentName} cannot be zero."); + } + + return argument; + } + + public static long NotBeNegativeOrZero( + [NotNull] this int? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument.Value.NotBeNegativeOrZero(); + } + + public static decimal NotBeNegativeOrZero( + this decimal argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == 0) + { + throw new ValidationException($"{argumentName} cannot be zero."); + } + + return argument; + } + + public static decimal NotBeNegativeOrZero( + [NotNull] this decimal? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument.Value.NotBeNegativeOrZero(); + } + + public static double NotBeNegativeOrZero( + this double argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument == 0) + { + throw new ValidationException($"{argumentName} cannot be zero."); + } + + return argument; + } + + public static double NotBeNegativeOrZero( + [NotNull] this double? argument, + [CallerArgumentExpression("argument")] string? argumentName = null + ) + { + if (argument is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + return argument.Value.NotBeNegativeOrZero(); + } + + public static string NotBeInvalidEmail( + this string email, + [CallerArgumentExpression("email")] string? argumentName = null + ) + { + // Use Regex to validate email format + var regex = new Regex(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$"); + if (!regex.IsMatch(email)) + { + throw new ValidationException($"{argumentName} is not a valid email address."); + } + + return email; + } + + public static string NotBeInvalidPhoneNumber( + this string phoneNumber, + [CallerArgumentExpression("phoneNumber")] string? argumentName = null + ) + { + // Use Regex to validate phone number format + var regex = new Regex(@"^[+]?(\d{1,2})?[\s.-]?(\d{3})[\s.-]?(\d{4})[\s.-]?(\d{4})$"); + if (!regex.IsMatch(phoneNumber)) + { + throw new ValidationException($"{argumentName} is not a valid phone number."); + } + + return phoneNumber; + } + + public static string NotBeInvalidMobileNumber( + this string mobileNumber, + [CallerArgumentExpression("mobileNumber")] string? argumentName = null + ) + { + // Use Regex to validate mobile number format + var regex = new Regex(@"^(?:(?:\+|00)([1-9]{1,3}))?([1-9]\d{9})$"); + if (!regex.IsMatch(mobileNumber)) + { + throw new ValidationException($"{argumentName} is not a valid mobile number."); + } + + return mobileNumber; + } + + public static string NotBeInvalidCurrency( + this string currency, + [CallerArgumentExpression("currency")] string? argumentName = null + ) + { + currency = currency.ToUpperInvariant(); + if (!_allowedCurrency.Contains(currency)) + { + throw new ValidationException($"{argumentName} is not a valid currency."); + } + + return currency; + } + + public static TEnum NotBeEmptyOrNull( + [NotNull] this TEnum? enumValue, + [CallerArgumentExpression("enumValue")] string argumentName = "" + ) + where TEnum : Enum + { + if (enumValue is null) + { + throw new ValidationException(message: $"{argumentName} cannot be null or empty."); + } + + enumValue.NotBeEmpty(); + + return enumValue; + } + + public static TEnum NotBeEmpty( + [NotNull] this TEnum enumValue, + [CallerArgumentExpression("enumValue")] string? argumentName = null + ) + where TEnum : Enum + { + enumValue.NotBeNull(); + if (enumValue.Equals(default(TEnum))) + { + throw new ValidationException( + $"The value of '{argumentName}' cannot be the default value of '{typeof(TEnum).Name}' enum." + ); + } + + return enumValue; + } + + public static void NotBeEmpty( + this DateTime dateTime, + [CallerArgumentExpression("dateTime")] string? argumentName = null + ) + { + var isEmpty = dateTime == DateTime.MinValue; + if (isEmpty) + { + throw new ValidationException( + $"The value of '{argumentName}' cannot be the default value of '{dateTime}'." + ); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/IdsGenerator/SnowFlakIdGenerator.cs b/src/BuildingBlocks/BuildingBlocks.Core/IdsGenerator/SnowFlakIdGenerator.cs new file mode 100644 index 00000000..14500c4d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/IdsGenerator/SnowFlakIdGenerator.cs @@ -0,0 +1,33 @@ +using IdGen; + +namespace BuildingBlocks.Core.IdsGenerator; + +public static class SnowFlakIdGenerator +{ + private static readonly IdGenerator _generator; + + static SnowFlakIdGenerator() + { + // Read `GENERATOR_ID` from .env file in service root folder or system environment variables + var generatorId = DotNetEnv.Env.GetInt("GENERATOR_ID", 0); + + // Let's say we take jan 17st 2022 as our epoch + var epoch = new DateTime(2022, 1, 17, 0, 0, 0, DateTimeKind.Local); + + // Create an ID with 45 bits for timestamp, 2 for generator-id + // and 16 for sequence + var structure = new IdStructure(45, 2, 16); + + // Prepare options + var options = new IdGeneratorOptions(structure, new DefaultTimeSource(epoch)); + + // Create an IdGenerator with it's generator-id set to 0, our custom epoch + // and id-structure + _generator = new IdGenerator(generatorId, options); + } + + public static long NewId() + { + return _generator.CreateId(); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/BackgroundServices/MessagePersistenceWorker.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/BackgroundServices/MessagePersistenceWorker.cs new file mode 100644 index 00000000..28672e68 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/BackgroundServices/MessagePersistenceWorker.cs @@ -0,0 +1,81 @@ +using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Abstractions.Types; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging.MessagePersistence; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BuildingBlocks.Core.Messaging.BackgroundServices; + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services +public class MessagePersistenceWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly IHostApplicationLifetime _lifetime; + private readonly MessagePersistenceOptions _options; + private readonly IMachineInstanceInfo _machineInstanceInfo; + + private Task? _executingTask; + + public MessagePersistenceWorker( + ILogger logger, + IOptions options, + IServiceProvider serviceProvider, + IHostApplicationLifetime lifetime, + IMachineInstanceInfo machineInstanceInfo + ) + { + _logger = logger; + _serviceProvider = serviceProvider; + _lifetime = lifetime; + _options = options.Value; + _machineInstanceInfo = machineInstanceInfo; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!await _lifetime.WaitForAppStartup(stoppingToken)) + { + return; + } + + _logger.LogInformation( + "MessagePersistence Background Service is starting on client '{@ClientId}' and group '{@ClientGroup}'", + _machineInstanceInfo.ClientId, + _machineInstanceInfo.ClientGroup + ); + + await ProcessAsync(stoppingToken); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation( + "MessagePersistence Background Service is stopping on client '{@ClientId}' and group '{@ClientGroup}'", + _machineInstanceInfo.ClientId, + _machineInstanceInfo.ClientGroup + ); + + return base.StopAsync(cancellationToken); + } + + private async Task ProcessAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await using (var scope = _serviceProvider.CreateAsyncScope()) + { + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessAllAsync(stoppingToken); + } + + var delay = _options.Interval is { } + ? TimeSpan.FromSeconds((int)_options.Interval) + : TimeSpan.FromSeconds(30); + + await Task.Delay(delay, stoppingToken); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/IntegrationEvent.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/IntegrationEvent.cs index ba68ba57..b9a3fc7d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/IntegrationEvent.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/IntegrationEvent.cs @@ -2,4 +2,4 @@ namespace BuildingBlocks.Core.Messaging; -public record IntegrationEvent : Message, IIntegrationEvent; +public abstract record IntegrationEvent : Message, IIntegrationEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/Message.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/Message.cs index 875f18cb..cd0d6a8f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/Message.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/Message.cs @@ -2,7 +2,7 @@ namespace BuildingBlocks.Core.Messaging; -public record Message : IMessage +public abstract record Message : IMessage { public Guid MessageId => Guid.NewGuid(); public DateTime Created { get; } = DateTime.Now; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/InMemory/InMemoryMessagePersistenceRepository.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/InMemory/InMemoryMessagePersistenceRepository.cs index 92d9346d..fa572cfe 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/InMemory/InMemoryMessagePersistenceRepository.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/InMemory/InMemoryMessagePersistenceRepository.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Linq.Expressions; -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Messaging.MessagePersistence.InMemory; @@ -12,7 +12,7 @@ public class InMemoryMessagePersistenceRepository : IMessagePersistenceRepositor public Task AddAsync(StoreMessage storeMessage, CancellationToken cancellationToken = default) { - Guard.Against.Null(storeMessage, nameof(storeMessage)); + storeMessage.NotBeNull(); _messages.TryAdd(storeMessage.Id, storeMessage); @@ -53,7 +53,7 @@ public Task> GetByFilterAsync( CancellationToken cancellationToken = default ) { - Guard.Against.Null(predicate, nameof(predicate)); + predicate.NotBeNull(); var result = _messages.Select(x => x.Value).Where(predicate.Compile()).ToImmutableList(); @@ -69,7 +69,7 @@ public Task> GetByFilterAsync( public Task RemoveAsync(StoreMessage storeMessage, CancellationToken cancellationToken = default) { - Guard.Against.Null(storeMessage, nameof(storeMessage)); + storeMessage.NotBeNull(); var result = _messages.Remove(storeMessage.Id, out _); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs index 365cd349..6aaab8ff 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs @@ -1,10 +1,10 @@ using System.Linq.Expressions; -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.CQRS.Commands; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; using BuildingBlocks.Abstractions.Serialization; +using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Types; using MediatR; using Microsoft.Extensions.Logging; @@ -94,7 +94,7 @@ private async Task AddMessageCore( CancellationToken cancellationToken = default ) { - Guard.Against.Null(messageEnvelope.Message, nameof(messageEnvelope.Message)); + messageEnvelope.Message.NotBeNull(); Guid id; if (messageEnvelope.Message is IMessage im) diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Paging/ApplicationSieveProcessor.cs b/src/BuildingBlocks/BuildingBlocks.Core/Paging/ApplicationSieveProcessor.cs new file mode 100644 index 00000000..ef7281d2 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Paging/ApplicationSieveProcessor.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace BuildingBlocks.Core.Paging; + +// https://github.com/Biarity/Sieve#modular-fluent-api-configuration +public class ApplicationSieveProcessor : SieveProcessor +{ + public ApplicationSieveProcessor(IOptions options) + : base(options) { } + + public ApplicationSieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) + : base(options, customSortMethods) { } + + public ApplicationSieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) + : base(options, customFilterMethods) { } + + public ApplicationSieveProcessor( + IOptions options, + ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods + ) + : base(options, customSortMethods, customFilterMethods) { } + + protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) + { + return mapper.ApplyConfigurationsFromAssembly(Assembly.GetCallingAssembly()); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageList.cs b/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageList.cs new file mode 100644 index 00000000..f789bdef --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageList.cs @@ -0,0 +1,34 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; + +namespace BuildingBlocks.Core.Paging; + +public record PageList(IReadOnlyList Items, int PageNumber, int PageSize, int TotalCount) : IPageList + where T : class +{ + public int CurrentPageSize => Items.Count; + public int CurrentStartIndex => TotalCount == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1; + public int CurrentEndIndex => TotalCount == 0 ? 0 : CurrentStartIndex + CurrentPageSize - 1; + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + public bool HasPrevious => PageNumber > 1; + public bool HasNext => PageNumber < TotalPages; + + public static PageList Empty => new(Enumerable.Empty().ToList(), 0, 0, 0); + + public static PageList Create(IReadOnlyList items, int pageNumber, int pageSize, int totalItems) + { + return new PageList(items, pageNumber, pageSize, totalItems); + } + + public IPageList MapTo(Func map) + where TR : class + { + return PageList.Create(Items.Select(map).ToList(), PageNumber, PageSize, TotalCount); + } + + public IPageList MapTo(IMapper mapper) + where TR : class + { + return PageList.Create(mapper.Map>(Items), PageNumber, PageSize, TotalCount); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageRequest.cs b/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageRequest.cs new file mode 100644 index 00000000..bae5d24e --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Paging/PageRequest.cs @@ -0,0 +1,41 @@ +using BuildingBlocks.Abstractions.Core.Paging; + +namespace BuildingBlocks.Core.Paging; + +// https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/records#characteristics-of-records +// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record +public record PageRequest : IPageRequest +{ + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; + public string? Filters { get; init; } + public string? SortOrder { get; init; } + + // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition + // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation + // https://alexanderzeitler.com/articles/deconstructing-a-csharp-record-with-properties/ + public void Deconstruct(out int pageNumber, out int pageSize, out string? filters, out string? sortOrder) => + (pageNumber, pageSize, filters, sortOrder) = (PageNumber, PageSize, Filters, SortOrder); + + //// This handle with [AsParameters] .net 7 + // https://blog.codingmilitia.com/2022/01/03/getting-complex-type-as-simple-type-query-string-aspnet-core-api-controller/ + // https://benfoster.io/blog/minimal-apis-custom-model-binding-aspnet- + // public static ValueTask BindAsync(HttpContext httpContext, ParameterInfo parameter) + // { + // var page = httpContext.Request.Query.Get("PageNumber", 1); + // var pageSize = httpContext.Request.Query.Get("PageSize", 20); + // var sorts = httpContext.Request.Query.Get("SortOrder"); + // var filters = httpContext.Request.Query.Get("Filters"); + // + // var request = new PageRequest + // { + // PageNumber = page, + // PageSize = pageSize, + // SortOrder = sorts, + // Filters = filters, + // }; + // + // return ValueTask.FromResult(request); + // } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfDbContextBase.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfDbContextBase.cs index 057982f6..4aa11d0b 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfDbContextBase.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfDbContextBase.cs @@ -1,8 +1,8 @@ using System.Collections.Immutable; using System.Data; using System.Linq.Expressions; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Abstractions.Persistence.EfCore; using Microsoft.EntityFrameworkCore; @@ -10,6 +10,8 @@ namespace BuildingBlocks.Core.Persistence.EfCore; +// https://learn.microsoft.com/en-us/ef/core/saving/transactions +// https://learn.microsoft.com/en-us/ef/core/saving/ public abstract class EfDbContextBase : DbContext, IDbFacadeResolver, IDbContext, IDomainEventContext { // private readonly IDomainEventPublisher _domainEventPublisher; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepository.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepository.cs new file mode 100644 index 00000000..f80e5925 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepository.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events; +using Microsoft.EntityFrameworkCore; +using Sieve.Services; + +namespace BuildingBlocks.Core.Persistence.EfCore; + +public class EfRepository : EfRepositoryBase + where TEntity : class, IHaveIdentity + where TDbContext : DbContext +{ + public EfRepository( + TDbContext dbContext, + ISieveProcessor sieveProcessor, + IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore + ) + : base(dbContext, sieveProcessor, aggregatesDomainEventsStore) { } +} + +public class EfRepository : EfRepository + where TEntity : class, IHaveIdentity + where TDbContext : DbContext +{ + public EfRepository( + TDbContext dbContext, + ISieveProcessor sieveProcessor, + IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore + ) + : base(dbContext, sieveProcessor, aggregatesDomainEventsStore) { } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepositoryBase.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepositoryBase.cs index bfc74268..7f0f9105 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepositoryBase.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfRepositoryBase.cs @@ -1,26 +1,35 @@ using System.Linq.Expressions; -using Ardalis.GuardClauses; -using BuildingBlocks.Abstractions.CQRS.Events; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; using BuildingBlocks.Abstractions.Domain; -using BuildingBlocks.Abstractions.Persistence.EfCore; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Core.Extensions; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Query; +using Sieve.Services; namespace BuildingBlocks.Core.Persistence.EfCore; -public abstract class EfRepositoryBase - : IEfRepository, - IPageRepository +public abstract class EfRepositoryBase : IRepository where TEntity : class, IHaveIdentity where TDbContext : DbContext { protected readonly TDbContext DbContext; + private readonly ISieveProcessor _sieveProcessor; private readonly IAggregatesDomainEventsRequestStore _aggregatesDomainEventsStore; protected readonly DbSet DbSet; - protected EfRepositoryBase(TDbContext dbContext, IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore) + protected EfRepositoryBase( + TDbContext dbContext, + ISieveProcessor sieveProcessor, + IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore + ) { DbContext = dbContext; + _sieveProcessor = sieveProcessor; _aggregatesDomainEventsStore = aggregatesDomainEventsStore; DbSet = dbContext.Set(); } @@ -35,7 +44,7 @@ protected EfRepositoryBase(TDbContext dbContext, IAggregatesDomainEventsRequestS CancellationToken cancellationToken = default ) { - Guard.Against.Null(predicate, nameof(predicate)); + predicate.NotBeNull(); return DbSet.SingleOrDefaultAsync(predicate, cancellationToken); } @@ -48,88 +57,72 @@ public async Task> FindAsync( return await DbSet.Where(predicate).ToListAsync(cancellationToken); } + public async Task AnyAsync( + Expression> predicate, + CancellationToken cancellationToken = default + ) + { + return await DbSet.AnyAsync(predicate, cancellationToken: cancellationToken); + } + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { return await DbSet.ToListAsync(cancellationToken); } - public virtual IEnumerable GetInclude( - Expression> predicate, - Func, IIncludableQueryable>? includes = null, - bool withTracking = true + public IAsyncEnumerable ProjectBy( + IConfigurationProvider configuration, + Expression>? predicate = null, + Expression>? sortExpression = null, + CancellationToken cancellationToken = default ) { - IQueryable query = DbSet; - - if (includes != null) - { - query = includes(query); - } - - query = query.Where(predicate); - - if (withTracking == false) + var query = DbSet.AsQueryable(); + if (predicate is not null) { - query = query.Where(predicate).AsNoTracking(); + query = query.Where(predicate); } - return query.AsEnumerable(); - } - - public virtual IEnumerable GetInclude( - Func, IIncludableQueryable>? includes = null - ) - { - IQueryable query = DbSet; - - if (includes != null) + if (sortExpression is not null) { - query = includes(query); + query = query.OrderByDescending(sortExpression); } - return query.AsEnumerable(); + return query.ProjectTo(configuration).ToAsyncEnumerable(); } - public virtual async Task> GetIncludeAsync( - Func, IIncludableQueryable>? includes = null + public async Task> GetByPageFilter( + IPageRequest pageRequest, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default ) { - IQueryable query = DbSet; - - if (includes != null) - { - query = includes(query); - } - - return await query.ToListAsync(); + return await DbSet.ApplyPagingAsync(pageRequest, _sieveProcessor, predicate, sortExpression, cancellationToken); } - public virtual async Task> GetIncludeAsync( - Expression> predicate, - Func, IIncludableQueryable>? includes = null, - bool withTracking = true + public async Task> GetByPageFilter( + IPageRequest pageRequest, + IConfigurationProvider configuration, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default ) + where TResult : class { - IQueryable query = DbSet; - - if (includes != null) - { - query = includes(query); - } - - query = query.Where(predicate); - - if (withTracking == false) - { - query = query.Where(predicate).AsNoTracking(); - } - - return await query.ToListAsync(); + return await DbSet.ApplyPagingAsync( + pageRequest, + _sieveProcessor, + configuration, + predicate, + sortExpression, + cancellationToken + ); } public async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) { - Guard.Against.Null(entity, nameof(entity)); + entity.NotBeNull(); await DbSet.AddAsync(entity, cancellationToken); @@ -138,7 +131,7 @@ public async Task AddAsync(TEntity entity, CancellationToken cancellati public Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) { - Guard.Against.Null(entity, nameof(entity)); + entity.NotBeNull(); var entry = DbContext.Entry(entity); entry.State = EntityState.Modified; @@ -148,7 +141,7 @@ public Task UpdateAsync(TEntity entity, CancellationToken cancellationT public async Task DeleteRangeAsync(IReadOnlyList entities, CancellationToken cancellationToken = default) { - Guard.Against.NullOrEmpty(entities, nameof(entities)); + entities.NotBeNull(); foreach (var entity in entities) { @@ -165,7 +158,7 @@ public Task DeleteAsync(Expression> predicate, CancellationT public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) { - Guard.Against.Null(entity, nameof(entity)); + entity.NotBeNull(); DbSet.Remove(entity); @@ -175,7 +168,8 @@ public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = de public async Task DeleteByIdAsync(TKey id, CancellationToken cancellationToken = default) { var item = await DbSet.SingleOrDefaultAsync(e => e.Id.Equals(id), cancellationToken); - Guard.Against.NotFound(id.ToString(), id.ToString(), nameof(id)); + if (item is null) + throw new NotFoundException($"Item with ID '{id}' not found"); DbSet.Remove(item); } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfTxBehavior.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfTxBehavior.cs index 680582bd..a9344031 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfTxBehavior.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfTxBehavior.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Abstractions.Serialization; using MediatR; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfUnitOfWork.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfUnitOfWork.cs index 596dd79c..49c449f4 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfUnitOfWork.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/EfUnitOfWork.cs @@ -1,5 +1,5 @@ -using BuildingBlocks.Abstractions.CQRS.Events; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence.EfCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -80,7 +80,7 @@ public Task RetryOnExceptionAsync(Func> operatio public void Dispose() { - _context.Dispose(); + GC.SuppressFinalize(this); } public Task ExecuteTransactionalAsync(Func action, CancellationToken cancellationToken = default) diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/Interceptors/ConcurrencyInterceptor.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/Interceptors/ConcurrencyInterceptor.cs index 0cd83582..8247a36d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/Interceptors/ConcurrencyInterceptor.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EfCore/Interceptors/ConcurrencyInterceptor.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events; using Microsoft.EntityFrameworkCore.Diagnostics; namespace BuildingBlocks.Core.Persistence.EfCore.Interceptors; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/AggregateStore.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/AggregateStore.cs index 6cb83661..557c8c4f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/AggregateStore.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/AggregateStore.cs @@ -1,9 +1,10 @@ using System.Collections.Immutable; -using Ardalis.GuardClauses; -using BuildingBlocks.Abstractions.CQRS.Events; +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Abstractions.Domain.Events; using BuildingBlocks.Abstractions.Domain.EventSourcing; using BuildingBlocks.Abstractions.Persistence.EventStore; using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Persistence.EventStore; @@ -24,7 +25,7 @@ public AggregateStore(IEventStore eventStore, IAggregatesDomainEventsRequestStor ) where TAggregate : class, IEventSourcedAggregate, new() { - Guard.Against.Null(aggregateId, nameof(aggregateId)); + aggregateId.NotBeNull(); var streamName = StreamName.For(aggregateId); @@ -48,7 +49,7 @@ public async Task StoreAsync( ) where TAggregate : class, IEventSourcedAggregate, new() { - Guard.Against.Null(aggregate, nameof(aggregate)); + aggregate.NotBeNull(); var streamName = StreamName.For(aggregate.Id); @@ -56,8 +57,19 @@ public async Task StoreAsync( var events = aggregate.GetUncommittedDomainEvents(); + // update events aggregateId and event versions + foreach (var item in events.Select((value, i) => new { index = i, value })) + { + item.value.WithAggregate(aggregate.Id, aggregate.CurrentVersion + (item.index + 1)); + } + var streamEvents = events - .Select(x => x.ToStreamEvent(new StreamEventMetadata(x.EventId.ToString(), x.AggregateSequenceNumber))) + .Select( + x => + x.ToStreamEvent( + new StreamEventMetadata(x.EventId.ToString(), (ulong)x.AggregateSequenceNumber, null, null) + ) + ) .ToImmutableList(); var result = await _eventStore.AppendEventsAsync(streamName, streamEvents, version, cancellationToken); @@ -87,7 +99,7 @@ public Task StoreAsync( public Task Exists(TId aggregateId, CancellationToken cancellationToken = default) where TAggregate : class, IEventSourcedAggregate, new() { - Guard.Against.Null(aggregateId, nameof(aggregateId)); + aggregateId.NotBeNull(); var streamName = StreamName.For(aggregateId); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/EventStoreDomainEventAccessor.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/EventStoreDomainEventAccessor.cs new file mode 100644 index 00000000..4b01838e --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/EventStoreDomainEventAccessor.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; + +namespace BuildingBlocks.Persistence.EventStoreDB; + +public class EventStoreDomainEventAccessor : IDomainEventsAccessor +{ + private readonly IAggregatesDomainEventsRequestStore _aggregatesDomainEventsStore; + + public EventStoreDomainEventAccessor(IAggregatesDomainEventsRequestStore aggregatesDomainEventsStore) + { + _aggregatesDomainEventsStore = aggregatesDomainEventsStore; + } + + public IReadOnlyList UnCommittedDomainEvents => + _aggregatesDomainEventsStore.GetAllUncommittedEvents(); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/ProjectionPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/ProjectionPublisher.cs index 3574b5c1..ac93f52e 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/ProjectionPublisher.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/ProjectionPublisher.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence.EventStore; using BuildingBlocks.Abstractions.Persistence.EventStore.Projections; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEvent.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEvent.cs index d4d64d64..0c18a0f8 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEvent.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEvent.cs @@ -1,15 +1,22 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence.EventStore; -using BuildingBlocks.Core.CQRS.Events; namespace BuildingBlocks.Core.Persistence.EventStore; -public record StreamEvent(IDomainEvent Data, IStreamEventMetadata? Metadata = null) : Event, IStreamEvent; - -public record StreamEvent(T Data, IStreamEventMetadata? Metadata = null) - : StreamEvent(Data, Metadata), - IStreamEvent +public record StreamEvent(T Data, IStreamEventMetadata Metadata) : IStreamEvent where T : IDomainEvent { - public new T Data => (T)base.Data; + object IStreamEvent.Data => Data; +} + +public record StreamEvent(object Data, IStreamEventMetadata Metadata) : IStreamEvent; + +public static class StreamEventFactory +{ + public static IStreamEvent From(object data, IStreamEventMetadata metadata) + { + //TODO: Get rid of reflection! + var type = typeof(StreamEvent<>).MakeGenericType(data.GetType()); + return (IStreamEvent)Activator.CreateInstance(type, data, metadata)!; + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventDataSerializationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventDataSerializationExtensions.cs index 269eacbc..9433478b 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventDataSerializationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventDataSerializationExtensions.cs @@ -1,5 +1,5 @@ using System.Text; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence.EventStore; using BuildingBlocks.Core.Types; using Newtonsoft.Json; @@ -43,7 +43,7 @@ public static StreamEventData ToJsonStreamEventData(this IStreamEvent @event) return ToJsonStreamEventData(@event.Data, @event.Metadata); } - public static StreamEventData ToJsonStreamEventData(this IDomainEvent @event, IStreamEventMetadata? metadata = null) + public static StreamEventData ToJsonStreamEventData(this object @event, IStreamEventMetadata? metadata = null) { return new StreamEventData { diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventExtensions.cs index fafff88f..d581687d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventExtensions.cs @@ -1,4 +1,4 @@ -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence.EventStore; using BuildingBlocks.Core.Reflection; using BuildingBlocks.Core.Utils; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventMetadata.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventMetadata.cs index 4ba96263..e03fc469 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventMetadata.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventMetadata.cs @@ -1,8 +1,11 @@ using BuildingBlocks.Abstractions.Persistence.EventStore; +using OpenTelemetry.Context.Propagation; namespace BuildingBlocks.Core.Persistence.EventStore; -public record StreamEventMetadata(string EventId, long StreamPosition) : IStreamEventMetadata -{ - public long? LogPosition { get; } -} +public record StreamEventMetadata( + string EventId, + ulong StreamPosition, + ulong? LogPosition, + PropagationContext? PropagationContext +) : IStreamEventMetadata; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamName.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamName.cs index 518e29f1..e4777639 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamName.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamName.cs @@ -1,5 +1,6 @@ -using Ardalis.GuardClauses; +using System.Diagnostics.CodeAnalysis; using BuildingBlocks.Abstractions.Domain.EventSourcing; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Persistence.EventStore; @@ -7,16 +8,20 @@ public class StreamName { public string Value { get; } - public StreamName(string value) + public StreamName([NotNull] string? value) { - Guard.Against.NullOrEmpty(value, nameof(value)); - Value = value; + Value = value.NotBeNull(); } - public static StreamName For(string id) => new($"{typeof(T).Name}-{Guard.Against.NullOrEmpty(id, nameof(id))}"); + public static StreamName For(string id) => new($"{typeof(T).Name}-{id.NotBeNullOrWhiteSpace()}"); public static StreamName For(TId aggregateId) - where TAggregate : IEventSourcedAggregate => For(aggregateId.ToString()); + where TAggregate : IEventSourcedAggregate + { + aggregateId.NotBeNull(); + var id = aggregateId.ToString().NotBeNullOrWhiteSpace(); + return For(id); + } public static implicit operator string(StreamName streamName) => streamName.Value; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/MigrationManager.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/MigrationManager.cs new file mode 100644 index 00000000..0ab08026 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/MigrationManager.cs @@ -0,0 +1,39 @@ +using BuildingBlocks.Abstractions.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace BuildingBlocks.Core.Persistence; + +// because our migration should apply first we should apply migration before running all background services with our MigrationManager and before `app.RunAsync()` for running host and workers +public class MigrationManager : IMigrationManager +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + public MigrationManager( + IServiceScopeFactory serviceScopeFactory, + ILogger logger, + IWebHostEnvironment environment + ) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + _environment = environment; + } + + public async Task ExecuteAsync(CancellationToken stoppingToken) + { + // https://stackoverflow.com/questions/38238043/how-and-where-to-call-database-ensurecreated-and-database-migrate + // https://www.michalbialecki.com/2020/07/20/adding-entity-framework-core-5-migrations-to-net-5-project/ + using var serviceScope = _serviceScopeFactory.CreateScope(); + var migrations = serviceScope.ServiceProvider.GetServices(); + + foreach (var migration in migrations) + { + _logger.LogInformation("Migration '{Migration}' started...", migrations.GetType().Name); + await migration.ExecuteAsync(stoppingToken); + _logger.LogInformation("Migration '{Migration}' ended...", migration.GetType().Name); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/SeedWorker.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/SeedWorker.cs new file mode 100644 index 00000000..bbd271a7 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/SeedWorker.cs @@ -0,0 +1,56 @@ +using BuildingBlocks.Abstractions.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BuildingBlocks.Core.Persistence; + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services +// Hint: we can't guarantee execution order of our seeder, and because our migration should apply first we should apply migration before running all background services with our MigrationManager and before `app.RunAsync()` for running host and workers +public class SeedWorker : BackgroundService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _webHostEnvironment; + + public SeedWorker( + IServiceScopeFactory serviceScopeFactory, + ILogger logger, + IWebHostEnvironment webHostEnvironment + ) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + _webHostEnvironment = webHostEnvironment; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_webHostEnvironment.IsEnvironment("test")) + { + _logger.LogInformation("Seed worker started"); + + // https://stackoverflow.com/questions/38238043/how-and-where-to-call-database-ensurecreated-and-database-migrate + // https://www.michalbialecki.com/2020/07/20/adding-entity-framework-core-5-migrations-to-net-5-project/ + using var serviceScope = _serviceScopeFactory.CreateScope(); + var seeders = serviceScope.ServiceProvider.GetServices(); + + foreach (var seeder in seeders.OrderBy(x => x.Order)) + { + _logger.LogInformation("Seeding '{Seed}' started...", seeder.GetType().Name); + await seeder.SeedAllAsync(); + _logger.LogInformation("Seeding '{Seed}' ended...", seeder.GetType().Name); + } + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + if (!_webHostEnvironment.IsEnvironment("test")) + { + _logger.LogInformation("Seed worker stopped"); + } + + return base.StopAsync(cancellationToken); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Reflection/Extensions/TypeExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Reflection/Extensions/TypeExtensions.cs index f908203c..ae26adc1 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Reflection/Extensions/TypeExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Reflection/Extensions/TypeExtensions.cs @@ -2,8 +2,8 @@ using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; -using BuildingBlocks.Abstractions.CQRS.Events; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Scheduler; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -660,7 +660,7 @@ bool addIfAlreadyExists if (addIfAlreadyExists) { - matches.ForEach(match => services.AddTransient(@interface, match)); + matches.ForEach(match => services.TryAddTransient(@interface, match)); } else { diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CQRSRegistrationRegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CQRSRegistrationRegistrationExtensions.cs new file mode 100644 index 00000000..af9e81b9 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CQRSRegistrationRegistrationExtensions.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Scheduler; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Domain.Events; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Scheduler; +using MediatR; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Core.Registrations; + +public static class CQRSRegistrationRegistrationExtensions +{ + public static IServiceCollection AddCqrs( + this IServiceCollection services, + Assembly[]? assemblies = null, + ServiceLifetime serviceLifetime = ServiceLifetime.Transient, + params Type[] pipelines + ) + { + services.AddMediatR( + assemblies ?? new[] { Assembly.GetCallingAssembly() }, + x => + { + switch (serviceLifetime) + { + case ServiceLifetime.Transient: + x.AsTransient(); + break; + case ServiceLifetime.Scoped: + x.AsScoped(); + break; + case ServiceLifetime.Singleton: + x.AsSingleton(); + break; + } + } + ); + + foreach (var pipeline in pipelines) + { + services.TryAddScoped(typeof(IPipelineBehavior<,>), pipeline); + } + + services + .Add(serviceLifetime) + .Add(serviceLifetime) + .Add(serviceLifetime) + .Add(); + + return services; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CoreRegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CoreRegistrationExtensions.cs index edcab8b7..21accc41 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CoreRegistrationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/CoreRegistrationExtensions.cs @@ -1,33 +1,37 @@ using System.Reflection; using BuildingBlocks.Abstractions.Core; -using BuildingBlocks.Abstractions.CQRS.Events; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Abstractions.Serialization; using BuildingBlocks.Abstractions.Types; -using BuildingBlocks.Core.CQRS.Events; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.Events; using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Core.IdsGenerator; using BuildingBlocks.Core.Messaging.BackgroundServices; using BuildingBlocks.Core.Messaging.MessagePersistence; using BuildingBlocks.Core.Messaging.MessagePersistence.InMemory; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Core.Persistence; using BuildingBlocks.Core.Reflection; using BuildingBlocks.Core.Serialization; using BuildingBlocks.Core.Types; using BuildingBlocks.Core.Utils; -using BuildingBlocks.Core.Web.Extenions.ServiceCollection; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Polly; using Scrutor; +using Sieve.Services; namespace BuildingBlocks.Core.Registrations; public static class CoreRegistrationExtensions { - public static IServiceCollection AddCore( - this IServiceCollection services, - IConfiguration configuration, - params Assembly[] scanAssemblies - ) + public static IServiceCollection AddCore(this IServiceCollection services, params Assembly[] scanAssemblies) { var systemInfo = MachineInstanceInfo.New(); @@ -37,33 +41,40 @@ params Assembly[] scanAssemblies ? scanAssemblies : ReflectionUtilities.GetReferencedAssemblies(Assembly.GetCallingAssembly()).Distinct().ToArray(); - services.AddSingleton(systemInfo); - services.AddSingleton(systemInfo); - services.AddSingleton(); + services.TryAddSingleton(systemInfo); + services.TryAddSingleton(systemInfo); + services.TryAddSingleton(); + + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + + services.TryAddScoped(); + services.ScanAndRegisterDbExecutors(assemblies); - services.AddTransient(); + var policy = Policy.Handle().RetryAsync(2); + services.TryAddSingleton(policy); services.AddHttpContextAccessor(); AddDefaultSerializer(services); - AddMessagingCore(services, configuration, assemblies); + AddMessagingCore(services, assemblies); RegisterEventMappers(services, assemblies); - switch (configuration["IdGenerator:Type"]) - { - case "Guid": - services.AddSingleton, GuidIdGenerator>(); - break; - default: - services.AddSingleton, SnowFlakIdGenerator>(); - break; - } + RegisterMigrationAndSeedManager(services); return services; } + private static void RegisterMigrationAndSeedManager(IServiceCollection services) + { + services.AddHostedService(); + services.TryAddScoped(); + } + private static void RegisterEventMappers(IServiceCollection services, Assembly[] scanAssemblies) { services.Scan( @@ -83,25 +94,21 @@ private static void RegisterEventMappers(IServiceCollection services, Assembly[] private static void AddMessagingCore( this IServiceCollection services, - IConfiguration configuration, Assembly[] scanAssemblies, ServiceLifetime serviceLifetime = ServiceLifetime.Transient ) { AddMessagingMediator(services, serviceLifetime, scanAssemblies); - AddPersistenceMessage(services, configuration); + AddPersistenceMessage(services); } - private static void AddPersistenceMessage(IServiceCollection services, IConfiguration configuration) + private static void AddPersistenceMessage(IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); - services.AddHostedService(); - services - .AddOptions() - .Bind(configuration.GetSection(nameof(MessagePersistenceOptions))) - .ValidateDataAnnotations(); + services.TryAddScoped(); + services.TryAddScoped(); + services.AddHostedService(); + services.AddValidatedOptions(); } private static void AddMessagingMediator( diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/DbExecutorRegistrationsExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/DbExecutorRegistrationsExtensions.cs new file mode 100644 index 00000000..52274283 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/DbExecutorRegistrationsExtensions.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Reflection.Extensions; + +namespace BuildingBlocks.Core.Registrations; + +public static class DbExecutorRegistrationsExtensions +{ + public static IServiceCollection ScanAndRegisterDbExecutors( + this IServiceCollection services, + IList assembliesToScan + ) + { + var dbExecutors = assembliesToScan + .SelectMany(x => x.GetLoadableTypes()) + .Where( + t => + t!.IsClass + && !t.IsAbstract + && !t.IsGenericType + && !t.IsInterface + && t.GetConstructor(Type.EmptyTypes) != null + && typeof(IDbExecutors).IsAssignableFrom(t) + ) + .ToList(); + + foreach (var dbExecutor in dbExecutors) + { + var instantiatedType = (IDbExecutors)Activator.CreateInstance(dbExecutor)!; + instantiatedType.Register(services); + } + + return services; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/EventStoreRegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/EventStoreRegistrationExtensions.cs new file mode 100644 index 00000000..3ec551de --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/EventStoreRegistrationExtensions.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Abstractions.Persistence.EventStore.Projections; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Persistence.EventStore; +using BuildingBlocks.Core.Persistence.EventStore.InMemory; +using BuildingBlocks.Core.Reflection; +using BuildingBlocks.Persistence.EventStoreDB; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Core.Registrations; + +public static class EventStoreRegistrationExtensions +{ + public static IServiceCollection AddInMemoryEventStore(this IServiceCollection services) + { + return AddEventSourcing(services, ServiceLifetime.Singleton); + } + + public static IServiceCollection AddEventSourcing( + this IServiceCollection services, + ServiceLifetime withLifetime = ServiceLifetime.Scoped, + params Assembly[] scanAssemblies + ) + where TEventStore : class, IEventStore + { + var assembliesToScan = scanAssemblies.Any() ? scanAssemblies : new[] { Assembly.GetCallingAssembly(), }; + + services.Add(withLifetime); + services.Add(withLifetime); + + services + .Add(withLifetime) + .Add(sp => sp.GetRequiredService(), withLifetime); + + services.AddReadProjections(assembliesToScan); + + return services; + } + + private static IServiceCollection AddReadProjections( + this IServiceCollection services, + params Assembly[] scanAssemblies + ) + { + services.TryAddSingleton(); + + // Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable. + var assemblies = scanAssemblies.Any() + ? scanAssemblies + : ReflectionUtilities.GetReferencedAssemblies(Assembly.GetCallingAssembly()).ToArray(); + + RegisterProjections(services, assemblies!); + + return services; + } + + private static void RegisterProjections(IServiceCollection services, Assembly[] assembliesToScan) + { + services.Scan( + scan => + scan.FromAssemblies(assembliesToScan) + .AddClasses(classes => classes.AssignableTo()) // Filter classes + .AsImplementedInterfaces() + .WithTransientLifetime() + ); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/InMemoryMessagingRegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/InMemoryMessagingRegistrationExtensions.cs index 4541ca16..4130e1cd 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Registrations/InMemoryMessagingRegistrationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Registrations/InMemoryMessagingRegistrationExtensions.cs @@ -1,6 +1,6 @@ using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Core.Messaging.MessagePersistence.InMemory; -using BuildingBlocks.Core.Web.Extenions.ServiceCollection; namespace BuildingBlocks.Core.Registrations; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Threading/NoSynchronizationContextScope.cs b/src/BuildingBlocks/BuildingBlocks.Core/Threading/NoSynchronizationContextScope.cs new file mode 100644 index 00000000..47d0464c --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Threading/NoSynchronizationContextScope.cs @@ -0,0 +1,23 @@ +namespace BuildingBlocks.Core.Threading; + +public static class NoSynchronizationContextScope +{ + public static Disposable Enter() + { + var context = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + return new Disposable(context); + } + + public readonly struct Disposable : IDisposable + { + private readonly SynchronizationContext? _synchronizationContext; + + public Disposable(SynchronizationContext? synchronizationContext) + { + _synchronizationContext = synchronizationContext; + } + + public void Dispose() => SynchronizationContext.SetSynchronizationContext(_synchronizationContext); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Types/Extensions/StringExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Types/Extensions/StringExtensions.cs index 7a3c3ee8..a87445ce 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Types/Extensions/StringExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Types/Extensions/StringExtensions.cs @@ -56,4 +56,24 @@ public static bool IsValidJson(this string strInput) return false; } + + // Ref: https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + public static int GetDeterministicHashCode(this string str) + { + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < str.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ str[i]; + if (i == str.Length - 1) + break; + hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; + } + + return hash1 + (hash2 * 1566083941); + } + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Types/MachineInstanceInfo.cs b/src/BuildingBlocks/BuildingBlocks.Core/Types/MachineInstanceInfo.cs index cd4af480..be47431c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Types/MachineInstanceInfo.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Types/MachineInstanceInfo.cs @@ -1,13 +1,14 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Types; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Core.Types; public record MachineInstanceInfo : IMachineInstanceInfo { - public MachineInstanceInfo(Guid clientId, string clientGroup) + public MachineInstanceInfo(Guid clientId, string? clientGroup) { - Guard.Against.NullOrEmpty(clientGroup, nameof(clientGroup)); + clientGroup.NotBeNullOrWhiteSpace(); + clientId.NotBeEmpty(); ClientId = clientId; ClientGroup = clientGroup; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Types/TypeMapper.cs b/src/BuildingBlocks/BuildingBlocks.Core/Types/TypeMapper.cs index 7b5bcc3d..9a0006a4 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Types/TypeMapper.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Types/TypeMapper.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; -using Ardalis.GuardClauses; -using BuildingBlocks.Core.Exception; +using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Reflection; -using BuildingBlocks.Core.Utils; namespace BuildingBlocks.Core.Types; @@ -72,8 +70,7 @@ private static void AddType(Type type, string name) private static string ToName(Type type, bool fullName = true) { - Guard.Against.Null(type); - + type.NotBeNull(); return TypeNameMap.GetOrAdd( type, _ => @@ -87,9 +84,9 @@ private static string ToName(Type type, bool fullName = true) ); } - private static Type ToType(string typeName) + private static Type ToType(string? typeName) { - Guard.Against.NullOrWhiteSpace(typeName, nameof(typeName)); + typeName.NotBeNull(); return TypeMap.GetOrAdd( typeName, diff --git a/src/BuildingBlocks/BuildingBlocks.Email/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.Email/Extensions.cs index 9fad6d53..4e53f94a 100644 --- a/src/BuildingBlocks/BuildingBlocks.Email/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Email/Extensions.cs @@ -1,7 +1,7 @@ using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; using BuildingBlocks.Email.Options; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace BuildingBlocks.Email; @@ -19,11 +19,11 @@ public static IServiceCollection AddEmailService( if (provider == EmailProvider.SendGrid) { - services.AddSingleton(); + services.TryAddSingleton(); } else { - services.AddSingleton(); + services.TryAddSingleton(); } if (configureOptions is { }) diff --git a/src/BuildingBlocks/BuildingBlocks.Email/IEmailSender.cs b/src/BuildingBlocks/BuildingBlocks.Email/IEmailSender.cs index 8d3c09ed..4dc9ba52 100644 --- a/src/BuildingBlocks/BuildingBlocks.Email/IEmailSender.cs +++ b/src/BuildingBlocks/BuildingBlocks.Email/IEmailSender.cs @@ -1,4 +1,4 @@ -using Ardalis.GuardClauses; +using BuildingBlocks.Core.Extensions; namespace BuildingBlocks.Email; @@ -11,22 +11,22 @@ public class EmailObject { public EmailObject(string receiverEmail, string subject, string mailBody) { - ReceiverEmail = Guard.Against.NullOrEmpty(receiverEmail, nameof(receiverEmail)); - Subject = Guard.Against.NullOrEmpty(subject, nameof(subject)); - MailBody = Guard.Against.NullOrEmpty(mailBody, nameof(mailBody)); + ReceiverEmail = receiverEmail.NotBeNullOrWhiteSpace(); + Subject = subject.NotBeNullOrWhiteSpace(); + MailBody = mailBody.NotBeNull(); } public EmailObject(string receiverEmail, string senderEmail, string subject, string mailBody) { - ReceiverEmail = Guard.Against.NullOrEmpty(receiverEmail, nameof(receiverEmail)); - SenderEmail = Guard.Against.NullOrEmpty(senderEmail, nameof(senderEmail)); - Subject = Guard.Against.NullOrEmpty(subject, nameof(subject)); - MailBody = Guard.Against.NullOrEmpty(mailBody, nameof(mailBody)); + ReceiverEmail = receiverEmail.NotBeNullOrWhiteSpace(); + Subject = subject.NotBeNullOrWhiteSpace(); + MailBody = mailBody.NotBeNull(); + SenderEmail = senderEmail.NotBeNullOrWhiteSpace(); } public string ReceiverEmail { get; } - public string SenderEmail { get; } + public string SenderEmail { get; } = default!; public string Subject { get; } diff --git a/src/BuildingBlocks/BuildingBlocks.Email/SendGridEmailSender.cs b/src/BuildingBlocks/BuildingBlocks.Email/SendGridEmailSender.cs index 0b8d28af..11f76ee4 100644 --- a/src/BuildingBlocks/BuildingBlocks.Email/SendGridEmailSender.cs +++ b/src/BuildingBlocks/BuildingBlocks.Email/SendGridEmailSender.cs @@ -1,4 +1,4 @@ -using Ardalis.GuardClauses; +using BuildingBlocks.Core.Extensions; using BuildingBlocks.Email.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,14 +16,14 @@ public class SendGridEmailSender : IEmailSender public SendGridEmailSender(IOptions emailConfig, ILogger logger) { _logger = logger; - _config = Guard.Against.Null(emailConfig?.Value, nameof(EmailOptions)); + _config = emailConfig.Value; } - private SendGridClient SendGridClient => new(_config.SendGridOptions.ApiKey); + private SendGridClient SendGridClient => new(_config.SendGridOptions?.ApiKey); public async Task SendAsync(EmailObject emailObject) { - Guard.Against.Null(emailObject, nameof(EmailObject)); + emailObject.NotBeNull(); var message = new SendGridMessage { Subject = emailObject.Subject, HtmlContent = emailObject.MailBody, }; diff --git a/src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj b/src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj index e489fa88..588a94a2 100644 --- a/src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj +++ b/src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj @@ -16,10 +16,15 @@ + + + + + @@ -29,6 +34,7 @@ + diff --git a/src/BuildingBlocks/BuildingBlocks.HealthCheck/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.HealthCheck/Extensions.cs index ef00991e..edf4b717 100644 --- a/src/BuildingBlocks/BuildingBlocks.HealthCheck/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.HealthCheck/Extensions.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.Json; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -22,16 +22,27 @@ public static class Extensions public static WebApplicationBuilder AddCustomHealthCheck( this WebApplicationBuilder builder, Action? healthChecksBuilder = null, - string sectionName = nameof(HealthOptions) + Action? configurator = null ) { - var healthOptions = builder.Configuration.BindOptions(sectionName); + var healthOptions = builder.Configuration.BindOptions(); + configurator?.Invoke(healthOptions); + + // add option to the dependency injection + builder.Services.AddValidationOptions(opt => configurator?.Invoke(opt)); + if (!healthOptions.Enabled) { return builder; } - var healCheckBuilder = builder.Services.AddHealthChecks().ForwardToPrometheus(); + var healCheckBuilder = builder.Services + .AddHealthChecks() + .AddDiskStorageHealthCheck(_ => { }, tags: new[] { "live", "ready" }) + .AddPingHealthCheck(_ => { }, tags: new[] { "live", "ready" }) + .AddPrivateMemoryHealthCheck(512 * 1024 * 1024, tags: new[] { "live", "ready" }) + .AddDnsResolveHealthCheck(_ => { }, tags: new[] { "live", "ready" }) + .ForwardToPrometheus(); healthChecksBuilder?.Invoke(healCheckBuilder); @@ -112,12 +123,26 @@ public static WebApplication UseCustomHealthCheck(this WebApplication app) } ) .UseHealthChecks( - "/health/ready", + "health/ready", new HealthCheckOptions { - Predicate = _ => true, - AllowCachingResponses = false, - ResponseWriter = WriteResponseAsync, + Predicate = check => check.Tags.Contains("ready"), + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + } + ) + .UseHealthChecks( + "health/live", + new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("live"), + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + } + ) + .UseHealthChecksPrometheusExporter( + "/health/prometheus", + options => + { + options.ResultStatusCodes[HealthStatus.Unhealthy] = 200; } ) .UseHealthChecksUI(setup => diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions.cs index bdb99dc7..3aefab2d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions.cs @@ -5,10 +5,11 @@ using BuildingBlocks.Core.Messaging; using BuildingBlocks.Core.Reflection; using BuildingBlocks.Core.Utils; -using BuildingBlocks.Core.Web.Extenions; using BuildingBlocks.Validation; using MassTransit; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using IBus = BuildingBlocks.Abstractions.Messaging.IBus; namespace BuildingBlocks.Integration.MassTransit; @@ -30,7 +31,7 @@ params Assembly[] scanAssemblies ? scanAssemblies : ReflectionUtilities.GetReferencedAssemblies(Assembly.GetCallingAssembly()).ToArray(); - if (!builder.Environment.IsTest()) + if (!builder.Environment.IsEnvironment("test")) { builder.Services.AddMassTransit(ConfiguratorAction); } @@ -103,7 +104,7 @@ void ConfiguratorAction(IBusRegistrationConfigurator busRegistrationConfigurator ); } - builder.Services.AddTransient(); + builder.Services.TryAddTransient(); return builder; } diff --git a/src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj b/src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj index 487bc481..6f8a1015 100644 --- a/src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj @@ -8,12 +8,15 @@ + + + diff --git a/src/BuildingBlocks/BuildingBlocks.Logging/Extensions/RegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Logging/Extensions/RegistrationExtensions.cs index 6646b2fe..47207aa9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Logging/Extensions/RegistrationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Logging/Extensions/RegistrationExtensions.cs @@ -1,10 +1,13 @@ using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using Microsoft.AspNetCore.Builder; using Serilog; using Serilog.Exceptions; +using Serilog.Exceptions.Core; +using Serilog.Exceptions.EntityFrameworkCore.Destructurers; using Serilog.Formatting.Elasticsearch; using Serilog.Sinks.Elasticsearch; +using Serilog.Sinks.Grafana.Loki; namespace BuildingBlocks.Logging; @@ -12,11 +15,15 @@ public static class RegistrationExtensions { public static WebApplicationBuilder AddCustomSerilog( this WebApplicationBuilder builder, - string sectionName = "Serilog", - Action? extraConfigure = null + Action? extraConfigure = null, + Action? configurator = null ) { - var serilogOptions = builder.Configuration.BindOptions(sectionName); + var serilogOptions = builder.Configuration.BindOptions(); + configurator?.Invoke(serilogOptions); + + // add option to the dependency injection + builder.Services.AddValidationOptions(opt => configurator?.Invoke(opt)); // https://andrewlock.net/creating-a-rolling-file-logging-provider-for-asp-net-core-2-0/ // https://github.com/serilog/serilog-extensions-hosting @@ -37,10 +44,14 @@ public static WebApplicationBuilder AddCustomSerilog( .Enrich.WithEnvironmentName() .Enrich.WithMachineName() // https://rehansaeed.com/logging-with-serilog-exceptions/ - .Enrich.WithExceptionDetails(); + .Enrich.WithExceptionDetails( + new DestructuringOptionsBuilder() + .WithDefaultDestructurers() + .WithDestructurers(new[] { new DbUpdateExceptionDestructurer() }) + ); // https://github.com/serilog/serilog-settings-configuration - loggerConfiguration.ReadFrom.Configuration(context.Configuration, sectionName: sectionName); + loggerConfiguration.ReadFrom.Configuration(context.Configuration, sectionName: nameof(SerilogOptions)); if (serilogOptions.UseConsole) { @@ -78,12 +89,32 @@ public static WebApplicationBuilder AddCustomSerilog( ); } + // https://github.com/serilog-contrib/serilog-sinks-grafana-loki + if (!string.IsNullOrEmpty(serilogOptions.GrafanaLokiUrl)) + { + loggerConfiguration.WriteTo.GrafanaLoki( + serilogOptions.GrafanaLokiUrl, + new[] + { + new LokiLabel { Key = "service", Value = "food-delivery" } + }, + new[] { "app" } + ); + } + if (!string.IsNullOrEmpty(serilogOptions.SeqUrl)) { // seq sink internally is async loggerConfiguration.WriteTo.Seq(serilogOptions.SeqUrl); } + // https://github.com/serilog/serilog-sinks-opentelemetry + if (serilogOptions.ExportLogsToOpenTelemetry) + { + // export logs from serilog to opentelemetry + loggerConfiguration.WriteTo.OpenTelemetry(); + } + if (!string.IsNullOrEmpty(serilogOptions.LogPath)) { loggerConfiguration.WriteTo.Async( diff --git a/src/BuildingBlocks/BuildingBlocks.Logging/LoggingBehavior.cs b/src/BuildingBlocks/BuildingBlocks.Logging/LoggingBehavior.cs index ba81650f..be63a9a6 100644 --- a/src/BuildingBlocks/BuildingBlocks.Logging/LoggingBehavior.cs +++ b/src/BuildingBlocks/BuildingBlocks.Logging/LoggingBehavior.cs @@ -1,22 +1,18 @@ using System.Diagnostics; -using BuildingBlocks.Abstractions.Serialization; using MediatR; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace BuildingBlocks.Logging; public class LoggingBehavior : IPipelineBehavior - where TRequest : notnull, IRequest - where TResponse : notnull + where TRequest : IRequest + where TResponse : class { private readonly ILogger> _logger; - private readonly ISerializer _serializer; - public LoggingBehavior(ILogger> logger, ISerializer serializer) + public LoggingBehavior(ILogger> logger) { _logger = logger; - _serializer = serializer; } public async Task Handle( @@ -25,54 +21,50 @@ public async Task Handle( CancellationToken cancellationToken ) { - // https://dotnetdocs.ir/Post/34/categorizing-logs-with-serilog-in-aspnet-core - using (Serilog.Context.LogContext.PushProperty("RequestObject", _serializer.Serialize(request))) - { - const string prefix = nameof(LoggingBehavior); + const string prefix = nameof(LoggingBehavior); + _logger.LogInformation( + "[{Prefix}] Handle request '{RequestData}' and response '{ResponseData}'", + prefix, + typeof(TRequest).Name, + typeof(TResponse).Name + ); + + var timer = new Stopwatch(); + timer.Start(); + + var response = await next(); + + timer.Stop(); + var timeTaken = timer.Elapsed; + if (timeTaken.Seconds > 3) + { + _logger.LogWarning( + "[{PerfPossible}] The request '{RequestData}' took '{TimeTaken}' seconds", + prefix, + typeof(TRequest).Name, + timeTaken.Seconds + ); + } + else + { _logger.LogInformation( - "[{Prefix}] Handle request={X-RequestData} and response={X-ResponseData}", + "[{PerfPossible}] The request '{RequestData}' took '{TimeTaken}' seconds", prefix, typeof(TRequest).Name, - typeof(TResponse).Name + timeTaken.Seconds ); + } - var timer = new Stopwatch(); - timer.Start(); - - var response = await next(); - - timer.Stop(); - var timeTaken = timer.Elapsed; - if (timeTaken.Seconds > 3) - { - _logger.LogWarning( - "[{Perf-Possible}] The request {X-RequestData} took {TimeTaken} seconds", - prefix, - typeof(TRequest).Name, - timeTaken.Seconds - ); - } - else - { - _logger.LogInformation( - "[{Perf-Possible}] The request {X-RequestData} took {TimeTaken} seconds", - prefix, - typeof(TRequest).Name, - timeTaken.Seconds - ); - } - - _logger.LogInformation("[{Prefix}] Handled {X-RequestData}", prefix, typeof(TRequest).Name); + _logger.LogInformation("[{Prefix}] Handled '{RequestData}'", prefix, typeof(TRequest).Name); - return response; - } + return response; } } public class StreamLoggingBehavior : IStreamPipelineBehavior - where TRequest : notnull, IStreamRequest - where TResponse : notnull + where TRequest : IStreamRequest + where TResponse : class { private readonly ILogger> _logger; @@ -81,7 +73,7 @@ public StreamLoggingBehavior(ILogger> _logger = logger; } - public IAsyncEnumerable Handle( + public async IAsyncEnumerable Handle( TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken @@ -90,7 +82,7 @@ CancellationToken cancellationToken const string prefix = nameof(StreamLoggingBehavior); _logger.LogInformation( - "[{Prefix}] Handle request={X-RequestData} and response={X-ResponseData}", + "[{Prefix}] Handle request '{RequestData}' and response '{ResponseData}'", prefix, typeof(TRequest).Name, typeof(TResponse).Name @@ -99,21 +91,22 @@ CancellationToken cancellationToken var timer = new Stopwatch(); timer.Start(); - var response = next(); - - timer.Stop(); - var timeTaken = timer.Elapsed; - if (timeTaken.Seconds > 3) + await foreach (var response in next().WithCancellation(cancellationToken)) { - _logger.LogWarning( - "[{Perf-Possible}] The request {X-RequestData} took {TimeTaken} seconds", - prefix, - typeof(TRequest).Name, - timeTaken.Seconds - ); - } + timer.Stop(); + var timeTaken = timer.Elapsed; + if (timeTaken.Seconds > 3) + { + _logger.LogWarning( + "[{PerfPossible}] The request '{RequestData}' took '{TimeTaken}' seconds", + prefix, + typeof(TRequest).Name, + timeTaken.Seconds + ); + } - _logger.LogInformation("[{Prefix}] Handled {X-RequestData}", prefix, typeof(TRequest).Name); - return response; + _logger.LogInformation("[{Prefix}] Handled '{RequestData}'", prefix, typeof(TRequest).Name); + yield return response; + } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Logging/SerilogOptions.cs b/src/BuildingBlocks/BuildingBlocks.Logging/SerilogOptions.cs index d39f6e2d..91508015 100644 --- a/src/BuildingBlocks/BuildingBlocks.Logging/SerilogOptions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Logging/SerilogOptions.cs @@ -1,10 +1,12 @@ namespace BuildingBlocks.Logging; -internal sealed class SerilogOptions +public sealed class SerilogOptions { public string? SeqUrl { get; set; } public bool UseConsole { get; set; } = true; + public bool ExportLogsToOpenTelemetry { get; set; } public string? ElasticSearchUrl { get; set; } + public string? GrafanaLokiUrl { get; set; } public bool UseElasticsearchJsonFormatter { get; set; } public string LogTemplate { get; set; } = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level} - {Message:lj}{NewLine}{Exception}"; diff --git a/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/ServiceCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/ServiceCollectionExtensions.cs index f7c2dea2..3329bc9b 100644 --- a/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/ServiceCollectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/ServiceCollectionExtensions.cs @@ -1,42 +1,43 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Core.Messaging.MessagePersistence; -using BuildingBlocks.Core.Web.Extenions.ServiceCollection; using BuildingBlocks.Messaging.Persistence.Postgres.MessagePersistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace BuildingBlocks.Messaging.Persistence.Postgres.Extensions; public static class ServiceCollectionExtensions { - public static void AddPostgresMessagePersistence(this IServiceCollection services, IConfiguration configuration) + public static void AddPostgresMessagePersistence( + this IServiceCollection services, + IConfiguration configuration, + Action? configurator = null + ) { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - services.AddValidatedOptions(nameof(MessagePersistenceOptions)); + var options = configuration.BindOptions(); + configurator?.Invoke(options); - services.AddScoped(sp => - { - var postgresOptions = sp.GetService(); - Guard.Against.NullOrEmpty(postgresOptions?.ConnectionString); + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); - return new NpgsqlMessagePersistenceConnectionFactory(postgresOptions.ConnectionString); - }); + services.TryAddScoped( + sp => new NpgsqlMessagePersistenceConnectionFactory(options.ConnectionString.NotBeEmptyOrNull()) + ); services.AddDbContext( - (sp, options) => + (sp, opt) => { - var postgresOptions = sp.GetRequiredService(); - - options - .UseNpgsql( - postgresOptions.ConnectionString, + opt.UseNpgsql( + options.ConnectionString, sqlOptions => { sqlOptions.MigrationsAssembly( - postgresOptions.MigrationAssembly - ?? typeof(MessagePersistenceDbContext).Assembly.GetName().Name + options.MigrationAssembly ?? typeof(MessagePersistenceDbContext).Assembly.GetName().Name ); sqlOptions.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), null); } diff --git a/src/BuildingBlocks/BuildingBlocks.OpenTelemetry/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.OpenTelemetry/Extensions.cs index 5c2153c0..4d5c4a71 100644 --- a/src/BuildingBlocks/BuildingBlocks.OpenTelemetry/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.OpenTelemetry/Extensions.cs @@ -1,10 +1,9 @@ using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Exporter; -using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -14,22 +13,33 @@ namespace BuildingBlocks.OpenTelemetry; public static class Extensions { - public static WebApplicationBuilder AddCustomOpenTelemetry(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddCustomOpenTelemetry( + this WebApplicationBuilder builder, + Action? configurator = null + ) { - var resourceBuilder = ResourceBuilder.CreateDefault().AddService(builder.Environment.ApplicationName); var options = builder.Configuration.BindOptions(); + configurator?.Invoke(options); - builder.Services.AddOpenTelemetryTracing(tracerProviderBuilder => - { - tracerProviderBuilder - .SetResourceBuilder(resourceBuilder) - .AddSource("MassTransit") // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/326 - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddEntityFrameworkCoreInstrumentation(); - - SetTracingExporters(options, tracerProviderBuilder); - }); + // add option to the dependency injection + builder.Services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + var resourceBuilder = ResourceBuilder.CreateDefault().AddService(builder.Environment.ApplicationName); + + builder.Services + .AddOpenTelemetry() + .WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder + .SetResourceBuilder(resourceBuilder) + // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/326 + .AddSource("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation(); + + SetTracingExporters(options, tracerProviderBuilder); + }); builder.Logging.AddOpenTelemetry(o => { @@ -38,28 +48,30 @@ public static WebApplicationBuilder AddCustomOpenTelemetry(this WebApplicationBu SetLogExporters(options, o); }); - builder.Services.AddOpenTelemetryMetrics(metrics => - { - metrics - .SetResourceBuilder(resourceBuilder) - .AddPrometheusExporter() - .AddAspNetCoreInstrumentation() - .AddRuntimeInstrumentation() - .AddHttpClientInstrumentation() - .AddEventCountersInstrumentation(c => - { - // https://learn.microsoft.com/en-us/dotnet/core/diagnostics/available-counters - c.AddEventSources( - "Microsoft.AspNetCore.Hosting", - "System.Net.Http", - "System.Net.Sockets", - "System.Net.NameResolution", - "System.Net.Security" - ); - }); - - SetMetricsExporters(options, metrics); - }); + builder.Services + .AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .SetResourceBuilder(resourceBuilder) + .AddPrometheusExporter() + .AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation() + .AddHttpClientInstrumentation() + .AddEventCountersInstrumentation(c => + { + // https://learn.microsoft.com/en-us/dotnet/core/diagnostics/available-counters + c.AddEventSources( + "Microsoft.AspNetCore.Hosting", + "System.Net.Http", + "System.Net.Sockets", + "System.Net.NameResolution", + "System.Net.Security" + ); + }); + + SetMetricsExporters(options, metrics); + }); builder.Services.Configure(opt => { @@ -68,12 +80,6 @@ public static WebApplicationBuilder AddCustomOpenTelemetry(this WebApplicationBu opt.IncludeFormattedMessage = true; }); - // For options which can be configured from code only. - builder.Services.Configure(aspNetCoreInstrumentationOptions => - { - aspNetCoreInstrumentationOptions.Filter = _ => true; - }); - return builder; } @@ -86,10 +92,10 @@ private static void SetLogExporters(OpenTelemetryOptions options, OpenTelemetryL break; case nameof(LogExporterType.OTLP): - loggerOptions.AddOtlpExporter(otlpOptions => - { - otlpOptions.Endpoint = new Uri(options.OTLPOptions.OTLPEndpoint); - }); + loggerOptions.AddOtlpExporter((otelExporterOptions, logRecorderOptions) => + { + otelExporterOptions.Endpoint = new Uri(options.OTLPOptions.OTLPEndpoint); + }); break; case nameof(LogExporterType.None): break; diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/NpgsqlConnectionFactory.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/NpgsqlConnectionFactory.cs index c2b3f075..6ded4a23 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/NpgsqlConnectionFactory.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/NpgsqlConnectionFactory.cs @@ -1,7 +1,7 @@ using System.Data; using System.Data.Common; -using Ardalis.GuardClauses; using BuildingBlocks.Abstractions.Persistence.EfCore; +using BuildingBlocks.Core.Extensions; using Npgsql; namespace Core.Persistence.Postgres; @@ -11,9 +11,9 @@ public class NpgsqlConnectionFactory : IConnectionFactory private readonly string _connectionString; private DbConnection? _connection; - public NpgsqlConnectionFactory(string connectionString) + public NpgsqlConnectionFactory(string? connectionString) { - Guard.Against.NullOrEmpty(connectionString); + connectionString.NotBeNullOrWhiteSpace(); _connectionString = connectionString; } @@ -32,5 +32,6 @@ public void Dispose() { if (_connection is { State: ConnectionState.Open }) _connection.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ServiceCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ServiceCollectionExtensions.cs index 6687199d..81709115 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ServiceCollectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ServiceCollectionExtensions.cs @@ -1,22 +1,16 @@ using System.Reflection; -using Ardalis.GuardClauses; -using BuildingBlocks.Abstractions.CQRS.Events; -using BuildingBlocks.Abstractions.CQRS.Events.Internal; -using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Domain.Events.Internal; using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Abstractions.Persistence.EfCore; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Core.Persistence.EfCore; using BuildingBlocks.Core.Persistence.EfCore.Interceptors; -using BuildingBlocks.Core.Web.Extenions.ServiceCollection; using Core.Persistence.Postgres; -using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Polly; -using Polly.Retry; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace BuildingBlocks.Persistence.EfCore.Postgres; @@ -24,35 +18,37 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddPostgresDbContext( this IServiceCollection services, + IConfiguration configuration, Assembly? migrationAssembly = null, - Action? builder = null + Action? builder = null, + Action? configurator = null, + params Assembly[] assembliesToScan ) where TDbContext : DbContext, IDbFacadeResolver, IDomainEventContext { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - services.AddValidatedOptions(nameof(PostgresOptions)); + var options = configuration.BindOptions(); + configurator?.Invoke(options); - services.AddScoped(sp => - { - var postgresOptions = sp.GetService(); - Guard.Against.NullOrEmpty(postgresOptions?.ConnectionString); - return new NpgsqlConnectionFactory(postgresOptions.ConnectionString); - }); + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + services.TryAddScoped( + sp => new NpgsqlConnectionFactory(options.ConnectionString.NotBeEmptyOrNull()) + ); services.AddDbContext( - (sp, options) => + (sp, dbContextOptionsBuilder) => { - var postgresOptions = sp.GetRequiredService(); - - options + dbContextOptionsBuilder .UseNpgsql( - postgresOptions.ConnectionString, + options.ConnectionString, sqlOptions => { var name = migrationAssembly?.GetName().Name - ?? postgresOptions.MigrationAssembly + ?? options.MigrationAssembly ?? typeof(TDbContext).Assembly.GetName().Name; sqlOptions.MigrationsAssembly(name); @@ -63,84 +59,63 @@ public static IServiceCollection AddPostgresDbContext( .UseSnakeCaseNamingConvention(); // ref: https://andrewlock.net/series/using-strongly-typed-entity-ids-to-avoid-primitive-obsession/ - options.ReplaceService>(); + dbContextOptionsBuilder.ReplaceService< + IValueConverterSelector, + StronglyTypedIdValueConverterSelector + >(); - options.AddInterceptors( + dbContextOptionsBuilder.AddInterceptors( new AuditInterceptor(), new SoftDeleteInterceptor(), new ConcurrencyInterceptor() ); - builder?.Invoke(options); + builder?.Invoke(dbContextOptionsBuilder); } ); - services.AddScoped(provider => provider.GetService()!); - services.AddScoped(provider => provider.GetService()!); - services.AddScoped(); + services.TryAddScoped(provider => provider.GetService()!); + services.TryAddScoped(provider => provider.GetService()!); + + services.AddPostgresRepositories(assembliesToScan); + services.AddPostgresUnitOfWork(assembliesToScan); return services; } - public static IServiceCollection AddPostgresCustomRepository( + private static IServiceCollection AddPostgresRepositories( this IServiceCollection services, - Type customRepositoryType + params Assembly[] assembliesToScan ) { + var scanAssemblies = assembliesToScan.Any() ? assembliesToScan : new[] { Assembly.GetCallingAssembly() }; services.Scan( scan => - scan.FromAssembliesOf(customRepositoryType) - .AddClasses(classes => classes.AssignableTo(customRepositoryType)) - .As(typeof(IRepository<,>)) - .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(customRepositoryType)) - .As(typeof(IPageRepository<>)) - .WithScopedLifetime() + scan.FromAssemblies(scanAssemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IRepository<,>)), false) + .AsImplementedInterfaces() + .AsSelf() + .WithTransientLifetime() ); return services; } - public static IServiceCollection AddPostgresRepository( - this IServiceCollection services, - ServiceLifetime lifeTime = ServiceLifetime.Scoped - ) - where TEntity : class, IAggregate - where TRepository : class, IRepository - { - return services.RegisterService, TRepository>(lifeTime); - } - - public static IServiceCollection AddUnitOfWork( + private static IServiceCollection AddPostgresUnitOfWork( this IServiceCollection services, - ServiceLifetime lifeTime = ServiceLifetime.Scoped, - bool registerGeneric = false + params Assembly[] assembliesToScan ) - where TContext : EfDbContextBase { - if (registerGeneric) - { - services.RegisterService>(lifeTime); - } - - return services.RegisterService, EfUnitOfWork>(lifeTime); - } + var scanAssemblies = assembliesToScan.Any() ? assembliesToScan : new[] { Assembly.GetCallingAssembly() }; + services.Scan( + scan => + scan.FromAssemblies(scanAssemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IEfUnitOfWork<>)), false) + .AsImplementedInterfaces() + .AsSelf() + .WithTransientLifetime() + ); - private static IServiceCollection RegisterService( - this IServiceCollection services, - ServiceLifetime lifeTime = ServiceLifetime.Scoped - ) - where TService : class - where TImplementation : class, TService - { - ServiceDescriptor serviceDescriptor = lifeTime switch - { - ServiceLifetime.Singleton => ServiceDescriptor.Singleton(), - ServiceLifetime.Scoped => ServiceDescriptor.Scoped(), - ServiceLifetime.Transient => ServiceDescriptor.Transient(), - _ => throw new ArgumentOutOfRangeException(nameof(lifeTime), lifeTime, null) - }; - services.Add(serviceDescriptor); return services; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.csproj b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.csproj new file mode 100644 index 00000000..6b366848 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbEventStore.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbEventStore.cs new file mode 100644 index 00000000..28d2038a --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbEventStore.cs @@ -0,0 +1,199 @@ +using System.Collections.Immutable; +using BuildingBlocks.Abstractions.Domain.EventSourcing; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Persistence.EventStoreDB.Extensions; +using EventStore.Client; + +namespace BuildingBlocks.Persistence.EventStoreDB; + +// https://developers.eventstore.com/clients/dotnet/21.2/migration-to-gRPC.html +public class EventStoreDbEventStore : IEventStore +{ + private readonly EventStoreClient _grpcClient; + + public EventStoreDbEventStore(EventStoreClient grpcClient) + { + _grpcClient = grpcClient; + } + + public async Task StreamExists(string streamId, CancellationToken cancellationToken = default) + { + var read = _grpcClient.ReadStreamAsync( + Direction.Forwards, + streamId, + StreamPosition.Start, + 1, + cancellationToken: cancellationToken + ); + + var state = await read.ReadState; + return state == ReadState.Ok; + } + + public async Task> GetStreamEventsAsync( + string streamId, + StreamReadPosition? fromVersion = null, + int maxCount = int.MaxValue, + CancellationToken cancellationToken = default + ) + { + var readResult = _grpcClient.ReadStreamAsync( + Direction.Forwards, + streamId, + fromVersion?.AsStreamPosition() ?? StreamPosition.Start, + maxCount, + cancellationToken: cancellationToken + ); + + var resolvedEvents = await readResult.ToListAsync(cancellationToken); + + return resolvedEvents.ToStreamEvents(); + } + + public Task> GetStreamEventsAsync( + string streamId, + StreamReadPosition? fromVersion = null, + CancellationToken cancellationToken = default + ) + { + return GetStreamEventsAsync(streamId, fromVersion, int.MaxValue, cancellationToken); + } + + public Task AppendEventAsync( + string streamId, + IStreamEvent @event, + CancellationToken cancellationToken = default + ) + { + return AppendEventsAsync( + streamId, + new List { @event }.ToImmutableList(), + ExpectedStreamVersion.NoStream, + cancellationToken + ); + } + + public Task AppendEventAsync( + string streamId, + IStreamEvent @event, + ExpectedStreamVersion expectedRevision, + CancellationToken cancellationToken = default + ) + { + return AppendEventsAsync( + streamId, + new List { @event }.ToImmutableList(), + expectedRevision, + cancellationToken + ); + } + + public async Task AppendEventsAsync( + string streamId, + IReadOnlyCollection events, + ExpectedStreamVersion expectedRevision, + CancellationToken cancellationToken = default + ) + { + var eventsData = events.Select(x => x.ToJsonEventData()); + + if (expectedRevision == ExpectedStreamVersion.NoStream) + { + var result = await _grpcClient.AppendToStreamAsync( + streamId, + StreamState.NoStream, + eventsData, + cancellationToken: cancellationToken + ); + + return new AppendResult( + (long)result.LogPosition.CommitPosition, + result.NextExpectedStreamRevision.ToInt64() + ); + } + + if (expectedRevision == ExpectedStreamVersion.Any) + { + var result = await _grpcClient.AppendToStreamAsync( + streamId, + StreamState.Any, + eventsData, + cancellationToken: cancellationToken + ); + + return new AppendResult( + (long)result.LogPosition.CommitPosition, + result.NextExpectedStreamRevision.ToInt64() + ); + } + else + { + var result = await _grpcClient.AppendToStreamAsync( + streamId, + expectedRevision.AsStreamRevision(), + eventsData, + cancellationToken: cancellationToken + ); + + return new AppendResult( + (long)result.LogPosition.CommitPosition, + result.NextExpectedStreamRevision.ToInt64() + ); + } + } + + public async Task AggregateStreamAsync( + string streamId, + StreamReadPosition fromVersion, + TAggregate defaultAggregateState, + Action fold, + CancellationToken cancellationToken = default + ) + where TAggregate : class, IEventSourcedAggregate, new() + { + var readResult = _grpcClient.ReadStreamAsync( + Direction.Forwards, + streamId, + fromVersion.AsStreamPosition(), + cancellationToken: cancellationToken + ); + + if (await readResult.ReadState.ConfigureAwait(false) == ReadState.StreamNotFound) + return null; + + // var streamEvents = (await GetStreamEventsAsync(streamId, fromVersion, int.MaxValue, cancellationToken)).Select(x => x.Data); + return await readResult + .Select(@event => @event.DeserializeData()!) + .AggregateAsync( + defaultAggregateState, + (agg, @event) => + { + fold(@event); + return agg; + }, + cancellationToken + ); + } + + public Task AggregateStreamAsync( + string streamId, + TAggregate defaultAggregateState, + Action fold, + CancellationToken cancellationToken = default + ) + where TAggregate : class, IEventSourcedAggregate, new() + { + return AggregateStreamAsync( + streamId, + StreamReadPosition.Start, + defaultAggregateState, + fold, + cancellationToken + ); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbOptions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbOptions.cs new file mode 100644 index 00000000..dbb91252 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/EventStoreDbOptions.cs @@ -0,0 +1,35 @@ +namespace BuildingBlocks.Persistence.EventStoreDB; + +// https://developers.eventstore.com/clients/dotnet/21.2/#connect-to-eventstoredb +// https://developers.eventstore.com/clients/http-api/v5 +// https://developers.eventstore.com/clients/grpc/ +// https://developers.eventstore.com/server/v20.10/networking.html#http-configuration +public class EventStoreDbOptions +{ + public bool UseInternalCheckpointing { get; set; } = true; + public string Host { get; set; } = default!; + + // HTTP is the primary protocol for EventStoreDB. It is used in gRPC communication and HTTP APIs (management, gossip and diagnostics). + public int HttpPort { get; set; } = 2113; + public int TcpPort { get; set; } = 1113; + + // https://developers.eventstore.com/server/v20.10/networking.html#http-configuration + // https://developers.eventstore.com/clients/grpc/#creating-a-client + public string GrpcConnectionString => $"esdb://{Host}:{HttpPort}?tls=false"; + + // https://developers.eventstore.com/clients/dotnet/21.2/#connect-to-eventstoredb + // https://developers.eventstore.com/server/v20.10/networking.html#external + public string TcpConnectionString => $"tcp://{Host}:{TcpPort}?tls=false"; + + // https://developers.eventstore.com/server/v20.10/networking.html#http-configuration + // https://developers.eventstore.com/clients/http-api/v5 + public string HttpConnectionString => $"http://{Host}:{HttpPort}"; + public EventStoreDbSubscriptionOptions SubscriptionOptions { get; set; } = null!; +} + +public class EventStoreDbSubscriptionOptions +{ + public string SubscriptionId { get; set; } = "default"; + public bool ResolveLinkTos { get; set; } + public bool IgnoreDeserializationErrors { get; set; } = true; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/EventStoreClientExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/EventStoreClientExtensions.cs new file mode 100644 index 00000000..4d02c60d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/EventStoreClientExtensions.cs @@ -0,0 +1,65 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using EventStore.Client; + +namespace BuildingBlocks.Persistence.EventStoreDB.Extensions; + +public static class EventStoreClientExtensions +{ + public static async Task Find( + this EventStoreClient eventStore, + Func getDefault, + Func when, + string streamId, + CancellationToken cancellationToken + ) + { + var readResult = eventStore.ReadStreamAsync( + Direction.Forwards, + streamId, + StreamPosition.Start, + cancellationToken: cancellationToken + ); + + return ( + await readResult + .Select(@event => @event.DeserializeData()!) + .AggregateAsync(getDefault(), when, cancellationToken) + )!; + } + + public static async Task Append( + this EventStoreClient eventStore, + string streamId, + TEvent @event, + CancellationToken cancellationToken + ) + where TEvent : IDomainEvent + { + var result = await eventStore.AppendToStreamAsync( + streamId, + StreamState.NoStream, + new[] { @event.ToJsonEventData() }, + cancellationToken: cancellationToken + ); + return result.NextExpectedStreamRevision; + } + + public static async Task Append( + this EventStoreClient eventStore, + string streamId, + TEvent @event, + ulong expectedRevision, + CancellationToken cancellationToken + ) + where TEvent : IDomainEvent + { + var result = await eventStore.AppendToStreamAsync( + streamId, + expectedRevision, + new[] { @event.ToJsonEventData() }, + cancellationToken: cancellationToken + ); + + return result.NextExpectedStreamRevision; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/RegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/RegistrationExtensions.cs new file mode 100644 index 00000000..47e51644 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/RegistrationExtensions.cs @@ -0,0 +1,49 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Persistence.EventStoreDB.Subscriptions; +using EventStore.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Persistence.EventStoreDB.Extensions; + +public static class RegistrationExtensions +{ + public static IServiceCollection AddEventStoreDb( + this IServiceCollection services, + IConfiguration configuration, + Action? configurator = null + ) + { + var options = configuration.BindOptions(); + configurator?.Invoke(options); + + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + services.TryAddSingleton(new EventStoreClient(EventStoreClientSettings.Create(options.GrpcConnectionString))); + + services.AddEventSourcing(); + + if (options.UseInternalCheckpointing) + { + services.TryAddTransient(); + } + + return services; + } + + public static IServiceCollection AddEventStoreDbSubscriptionToAll( + this IServiceCollection services, + bool checkpointToEventStoreDb = true + ) + { + if (checkpointToEventStoreDb) + { + services.TryAddTransient(); + } + + return services.AddHostedService(); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/SerializationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/SerializationExtensions.cs new file mode 100644 index 00000000..8d4e8c5c --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/SerializationExtensions.cs @@ -0,0 +1,60 @@ +using System.Text; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Core.Persistence.EventStore; +using BuildingBlocks.Core.Types; +using EventStore.Client; +using Newtonsoft.Json; + +namespace BuildingBlocks.Persistence.EventStoreDB.Extensions; + +public static class SerializationExtensions +{ + public static T DeserializeData(this ResolvedEvent resolvedEvent) => (T)DeserializeData(resolvedEvent); + + public static object DeserializeData(this ResolvedEvent resolvedEvent) + { + // get type + var eventType = TypeMapper.GetType(resolvedEvent.Event.EventType); + + // deserialize event + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(resolvedEvent.Event.Data.Span), eventType)!; + } + + public static IStreamEventMetadata DeserializeMetadata(this ResolvedEvent resolvedEvent) + { + // deserialize event + return JsonConvert.DeserializeObject( + Encoding.UTF8.GetString(resolvedEvent.Event.Metadata.Span) + )!; + } + + public static EventData ToJsonEventData(this IStreamEvent @event) + { + return ToJsonEventData(@event.Data, @event.Metadata); + } + + public static EventData ToJsonEventData(this object @event, IStreamEventMetadata? metadata = null) + { + return new( + Uuid.NewUuid(), + TypeMapper.GetTypeNameByObject(@event), + Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(@event)), + Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(metadata ?? new object())) + ); + } + + public static StreamEvent? ToStreamEvent(this ResolvedEvent resolvedEvent) + { + var eventData = resolvedEvent.DeserializeData(); + var metaData = resolvedEvent.DeserializeMetadata(); + + // var metaData = new StreamEventMetadata( + // resolvedEvent.Event.EventId.ToString(), + // resolvedEvent.Event.EventNumber.ToInt64()); + + var type = typeof(StreamEvent<>).MakeGenericType(eventData.GetType()); + + return (StreamEvent?)Activator.CreateInstance(type, eventData, metaData); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamEventExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamEventExtensions.cs new file mode 100644 index 00000000..85a82ee3 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamEventExtensions.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Core.Persistence.EventStore; +using EventStore.Client; + +namespace BuildingBlocks.Persistence.EventStoreDB.Extensions; + +public static class StreamEventExtensions +{ + public static IEnumerable ToStreamEvents(this IEnumerable resolvedEvents) + { + return resolvedEvents.Select(x => x.ToStreamEvent()); + } + + // public static IStreamEvent? ToStreamEvent(this ResolvedEvent resolvedEvent) + // { + // var eventData = resolvedEvent.Deserialize(); + // var eventMetadata = resolvedEvent.DeserializePropagationContext(); + // + // if (eventData == null) + // return null; + // + // var metaData = new StreamEventMetadata( + // resolvedEvent.Event.EventId.ToString(), + // resolvedEvent.Event.EventNumber.ToUInt64(), + // resolvedEvent.Event.Position.CommitPosition, + // eventMetadata + // ); + // + // return StreamEventFactory.From(eventData, metaData); + // } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamRevisionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamRevisionExtensions.cs new file mode 100644 index 00000000..2ebcf055 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Extensions/StreamRevisionExtensions.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Persistence.EventStore; +using EventStore.Client; + +namespace BuildingBlocks.Persistence.EventStoreDB.Extensions; + +public static class StreamRevisionExtensions +{ + public static StreamRevision AsStreamRevision(this ExpectedStreamVersion version) => + StreamRevision.FromInt64(version.Value); + + public static StreamPosition AsStreamPosition(this StreamTruncatePosition position) => + StreamPosition.FromInt64(position.Value); + + public static StreamPosition AsStreamPosition(this StreamReadPosition position) => + StreamPosition.FromInt64(position.Value); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/EventStoreDbSubscriptionCheckPointRepository.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/EventStoreDbSubscriptionCheckPointRepository.cs new file mode 100644 index 00000000..d1fad361 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/EventStoreDbSubscriptionCheckPointRepository.cs @@ -0,0 +1,79 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Persistence.EventStoreDB.Extensions; +using EventStore.Client; + +namespace BuildingBlocks.Persistence.EventStoreDB.Subscriptions; + +public record CheckpointStored(string SubscriptionId, ulong? Position, DateTime CheckPointedAt) : DomainEvent; + +public class EventStoreDbSubscriptionCheckPointRepository : ISubscriptionCheckpointRepository +{ + private readonly EventStoreClient _eventStoreClient; + + public EventStoreDbSubscriptionCheckPointRepository(EventStoreClient eventStoreClient) + { + this._eventStoreClient = eventStoreClient ?? throw new ArgumentNullException(nameof(eventStoreClient)); + } + + public async ValueTask Load(string subscriptionId, CancellationToken ct) + { + var streamName = GetCheckpointStreamName(subscriptionId); + + var result = _eventStoreClient.ReadStreamAsync( + Direction.Backwards, + streamName, + StreamPosition.End, + 1, + cancellationToken: ct + ); + + if (await result.ReadState == ReadState.StreamNotFound) + { + return null; + } + + ResolvedEvent? @event = await result.FirstOrDefaultAsync(ct); + + return @event?.DeserializeData().Position; + } + + public async ValueTask Store(string subscriptionId, ulong position, CancellationToken ct) + { + var @event = new CheckpointStored(subscriptionId, position, DateTime.UtcNow); + var eventToAppend = new[] { @event.ToJsonEventData() }; + var streamName = GetCheckpointStreamName(subscriptionId); + + try + { + // store new checkpoint expecting stream to exist + await _eventStoreClient.AppendToStreamAsync( + streamName, + StreamState.StreamExists, + eventToAppend, + cancellationToken: ct + ); + } + catch (WrongExpectedVersionException) + { + // WrongExpectedVersionException means that stream did not exist + // Set the checkpoint stream to have at most 1 event + // using stream metadata $maxCount property + await _eventStoreClient.SetStreamMetadataAsync( + streamName, + StreamState.NoStream, + new StreamMetadata(1), + cancellationToken: ct + ); + + // append event again expecting stream to not exist + await _eventStoreClient.AppendToStreamAsync( + streamName, + StreamState.NoStream, + eventToAppend, + cancellationToken: ct + ); + } + } + + private static string GetCheckpointStreamName(string subscriptionId) => $"checkpoint_{subscriptionId}"; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/ISubscriptionCheckpointRepository.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/ISubscriptionCheckpointRepository.cs new file mode 100644 index 00000000..075ea8f0 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/ISubscriptionCheckpointRepository.cs @@ -0,0 +1,8 @@ +namespace BuildingBlocks.Persistence.EventStoreDB.Subscriptions; + +public interface ISubscriptionCheckpointRepository +{ + ValueTask Load(string subscriptionId, CancellationToken ct); + + ValueTask Store(string subscriptionId, ulong position, CancellationToken ct); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/InMemorySubscriptionCheckpointRepository.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/InMemorySubscriptionCheckpointRepository.cs new file mode 100644 index 00000000..78e5e60e --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/InMemorySubscriptionCheckpointRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace BuildingBlocks.Persistence.EventStoreDB.Subscriptions; + +public class InMemorySubscriptionCheckpointRepository : ISubscriptionCheckpointRepository +{ + private readonly ConcurrentDictionary checkpoints = new(); + + public ValueTask Load(string subscriptionId, CancellationToken ct) + { + return new(checkpoints.TryGetValue(subscriptionId, out var checkpoint) ? checkpoint : null); + } + + public ValueTask Store(string subscriptionId, ulong position, CancellationToken ct) + { + checkpoints.AddOrUpdate(subscriptionId, position, (_, _) => position); + + return ValueTask.CompletedTask; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/SubscribeToAllBackgroundWorker.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/SubscribeToAllBackgroundWorker.cs new file mode 100644 index 00000000..9e18f3fa --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/Subscriptions/SubscribeToAllBackgroundWorker.cs @@ -0,0 +1,219 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Persistence.EventStore.Projections; +using BuildingBlocks.Core.Threading; +using BuildingBlocks.Core.Types; +using BuildingBlocks.Persistence.EventStoreDB.Extensions; +using EventStore.Client; +using Grpc.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BuildingBlocks.Persistence.EventStoreDB.Subscriptions; + +// Ref: https://github.com/oskardudycz/EventSourcing.NetCore/ +public class EventStoreDBSubscriptionToAll : BackgroundService +{ + private readonly EventStoreDbOptions _eventStoreDbOptions; + private readonly EventStoreClient _eventStoreClient; + private readonly IReadProjectionPublisher _projectionPublisher; + private readonly IInternalEventBus _internalEventBus; + private readonly ISubscriptionCheckpointRepository _checkpointRepository; + private readonly ILogger _logger; + private readonly object _resubscribeLock = new(); + private CancellationToken _cancellationToken; + + public EventStoreDBSubscriptionToAll( + IOptions eventStoreDbOptions, + EventStoreClient eventStoreClient, + IReadProjectionPublisher projectionPublisher, + IInternalEventBus internalEventBus, + ISubscriptionCheckpointRepository checkpointRepository, + ILogger logger + ) + { + _eventStoreDbOptions = eventStoreDbOptions.Value; + _eventStoreClient = eventStoreClient; + _projectionPublisher = projectionPublisher; + _internalEventBus = internalEventBus; + _checkpointRepository = checkpointRepository; + _logger = logger; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _cancellationToken = stoppingToken; + + return SubscribeToAll(stoppingToken); + } + + private async Task SubscribeToAll(CancellationToken cancellationToken = default) + { + // see: https://github.com/dotnet/runtime/issues/36063 + await Task.Yield(); + + _logger.LogInformation( + "Subscription to all '{SubscriptionId}'", + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId + ); + + var checkpoint = await _checkpointRepository.Load( + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId, + cancellationToken + ); + + await _eventStoreClient.SubscribeToAllAsync( + checkpoint == null ? FromAll.Start : FromAll.After(new Position(checkpoint.Value, checkpoint.Value)), + HandleEvent, + _eventStoreDbOptions.SubscriptionOptions.ResolveLinkTos, + HandleDrop, + new(EventTypeFilter.ExcludeSystemEvents()), + default, + cancellationToken + ); + + _logger.LogInformation( + "Subscription to all '{SubscriptionId}' started", + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId + ); + } + + private async Task HandleEvent( + StreamSubscription subscription, + ResolvedEvent resolvedEvent, + CancellationToken cancellationToken = default + ) + { + try + { + if (IsEventWithEmptyData(resolvedEvent) || IsCheckpointEvent(resolvedEvent)) + return; + + var streamEvent = resolvedEvent.ToStreamEvent(); + + if (streamEvent == null) + { + // That can happen if we're sharing database between modules. + // If we're subscribing to all and not filtering out events from other modules, + // then we might get events that are from other module and we might not be able to deserialize them. + // In that case it's safe to ignore deserialization error. + // You may add more sophisticated logic checking if it should be ignored or not. + _logger.LogWarning("Couldn't deserialize event with id: {EventId}", resolvedEvent.Event.EventId); + + if (!_eventStoreDbOptions.SubscriptionOptions.IgnoreDeserializationErrors) + { + throw new InvalidOperationException( + $"Unable to deserialize event {resolvedEvent.Event.EventType} with id: {resolvedEvent.Event.EventId}" + ); + } + + return; + } + + // publish event to internal event bus + await _internalEventBus.Publish(streamEvent, cancellationToken); + + await _projectionPublisher.PublishAsync(streamEvent, cancellationToken); + + await _checkpointRepository.Store( + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId, + resolvedEvent.Event.Position.CommitPosition, + cancellationToken + ); + } + catch (Exception e) + { + _logger.LogError( + "Error consuming message: {ExceptionMessage}{ExceptionStackTrace}", + e.Message, + e.StackTrace + ); + + // if you're fine with dropping some events instead of stopping subscription + // then you can add some logic if error should be ignored + throw; + } + } + + private void HandleDrop( + StreamSubscription streamSubscription, + SubscriptionDroppedReason reason, + Exception? exception + ) + { + _logger.LogError( + exception, + "Subscription to all '{SubscriptionId}' dropped with '{Reason}'", + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId, + reason + ); + + if (exception is RpcException { StatusCode: StatusCode.Cancelled }) + return; + + Resubscribe(); + } + + private void Resubscribe() + { + // You may consider adding a max resubscribe count if you want to fail process + // instead of retrying until database is up + while (true) + { + var resubscribed = false; + try + { + Monitor.Enter(_resubscribeLock); + + // No synchronization consumeContext is needed to disable synchronization consumeContext. + // That enables running asynchronous method not causing deadlocks. + // As this is a background process then we don't need to have async consumeContext here. + using (NoSynchronizationContextScope.Enter()) + { + SubscribeToAll(_cancellationToken).Wait(_cancellationToken); + } + + resubscribed = true; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to resubscribe to all '{SubscriptionId}' dropped with '{ExceptionMessage}{ExceptionStackTrace}'", + _eventStoreDbOptions.SubscriptionOptions.SubscriptionId, + exception.Message, + exception.StackTrace + ); + } + finally + { + Monitor.Exit(_resubscribeLock); + } + + if (resubscribed) + break; + + // Sleep between reconnections to not flood the database or not kill the CPU with infinite loop + // Randomness added to reduce the chance of multiple subscriptions trying to reconnect at the same time + Thread.Sleep(1000 + new Random((int)DateTime.UtcNow.Ticks).Next(1000)); + } + } + + private bool IsEventWithEmptyData(ResolvedEvent resolvedEvent) + { + if (resolvedEvent.Event.Data.Length != 0) + return false; + + _logger.LogInformation("Event without data received"); + return true; + } + + private bool IsCheckpointEvent(ResolvedEvent resolvedEvent) + { + if (resolvedEvent.Event.EventType != TypeMapper.GetTypeName()) + return false; + + _logger.LogInformation("Checkpoint event - ignoring"); + return true; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/readme.md b/src/BuildingBlocks/BuildingBlocks.Persistence.EventStoreDB/readme.md new file mode 100644 index 00000000..e69de29b diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/BuildingBlocks.Persistence.Marten.csproj b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/BuildingBlocks.Persistence.Marten.csproj new file mode 100644 index 00000000..777f88b7 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/BuildingBlocks.Persistence.Marten.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Extensions/ServiceCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f9917a09 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Persistence.Marten.Subscriptions; +using Marten; +using Marten.Events.Daemon.Resiliency; +using Marten.Events.Projections; +using MediatR; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Weasel.Core; + +namespace BuildingBlocks.Persistence.Marten.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMartenDB( + this IServiceCollection services, + IConfiguration configuration, + Action? configureOptions = null, + Action? configurator = null, + params Assembly[] scanAssemblies + ) + { + var assembliesToScan = scanAssemblies.Any() ? scanAssemblies : new[] { Assembly.GetCallingAssembly(), }; + + var martenOptions = configuration.BindOptions(); + configurator?.Invoke(martenOptions); + + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + services.AddEventSourcing(ServiceLifetime.Scoped, assembliesToScan); + + services + .AddMarten(sp => + { + var storeOptions = new StoreOptions(); + storeOptions.Connection(martenOptions.ConnectionString); + storeOptions.AutoCreateSchemaObjects = AutoCreate.CreateOrUpdate; + + var schemaName = Environment.GetEnvironmentVariable("SchemaName"); + storeOptions.Events.DatabaseSchemaName = schemaName ?? martenOptions.WriteModelSchema; + storeOptions.DatabaseSchemaName = schemaName ?? martenOptions.ReadModelSchema; + + storeOptions.UseDefaultSerialization( + EnumStorage.AsString, + nonPublicMembersStorage: NonPublicMembersStorage.All + ); + + storeOptions.Projections.Add( + new MartenSubscription( + new[] { new MartenEventPublisher(sp.GetRequiredService()) }, + sp.GetRequiredService>() + ), + ProjectionLifecycle.Async, + "MartenSubscription" + ); + + if (martenOptions.UseMetadata) + { + storeOptions.Events.MetadataConfig.CausationIdEnabled = true; + storeOptions.Events.MetadataConfig.CorrelationIdEnabled = true; + storeOptions.Events.MetadataConfig.HeadersEnabled = true; + } + + configureOptions?.Invoke(storeOptions); + + return storeOptions; + }) + .UseLightweightSessions() + .ApplyAllDatabaseChangesOnStartup() + //.OptimizeArtifactWorkflow() + .AddAsyncDaemon(DaemonMode.Solo); + + return services; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenEventStore.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenEventStore.cs new file mode 100644 index 00000000..b656edfe --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenEventStore.cs @@ -0,0 +1,143 @@ +using System.Collections.Immutable; +using BuildingBlocks.Abstractions.Domain.EventSourcing; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using Marten; + +namespace BuildingBlocks.Persistence.Marten; + +public class MartenEventStore : IEventStore +{ + private readonly IDocumentSession _documentSession; + + public MartenEventStore(IDocumentSession documentSession) + { + _documentSession = documentSession; + } + + public Task StreamExists(string streamId, CancellationToken cancellationToken = default) + { + var state = _documentSession.Events.FetchStreamState(streamId); + + return Task.FromResult(state != null); + } + + public async Task> GetStreamEventsAsync( + string streamId, + StreamReadPosition? fromVersion = null, + int maxCount = int.MaxValue, + CancellationToken cancellationToken = default + ) + { + var events = await Filter(streamId, fromVersion?.Value, null).ToListAsync(cancellationToken); + + // events that we saved are IStreamEvent + var streamEvents = events.Select(ev => ev.Data).OfType().ToImmutableList(); + + return streamEvents; + } + + public Task> GetStreamEventsAsync( + string streamId, + StreamReadPosition? fromVersion = null, + CancellationToken cancellationToken = default + ) + { + return GetStreamEventsAsync(streamId, fromVersion, int.MaxValue, cancellationToken); + } + + public Task AppendEventAsync( + string streamId, + IStreamEvent @event, + CancellationToken cancellationToken = default + ) + { + // storing whole stream event with metadata because there is no way to store metadata separately + var result = _documentSession.Events.Append(streamId, @event); + + var nextVersion = _documentSession.Events.FetchStream(streamId).Count; + + return Task.FromResult(new AppendResult(-1, nextVersion)); + } + + public Task AppendEventAsync( + string streamId, + IStreamEvent @event, + ExpectedStreamVersion expectedRevision, + CancellationToken cancellationToken = default + ) + { + return AppendEventsAsync(streamId, new[] { @event }, expectedRevision: expectedRevision, cancellationToken); + } + + public Task AppendEventsAsync( + string streamId, + IReadOnlyCollection events, + ExpectedStreamVersion expectedRevision, + CancellationToken cancellationToken = default + ) + { + // storing whole stream event with metadata because there is no way to store metadata separately + var result = _documentSession.Events.Append( + streamId, + expectedVersion: expectedRevision.Value, + events: events.Cast().ToArray() + ); + + var nextVersion = expectedRevision.Value + events.Count; + + return Task.FromResult(new AppendResult(-1, nextVersion)); + } + + public Task AggregateStreamAsync( + string streamId, + StreamReadPosition fromVersion, + TAggregate defaultAggregateState, + Action fold, + CancellationToken cancellationToken = default + ) + where TAggregate : class, IEventSourcedAggregate, new() + { + return _documentSession.Events.AggregateStreamAsync( + streamId, + version: fromVersion.Value, + null, + token: cancellationToken + ); + } + + public Task AggregateStreamAsync( + string streamId, + TAggregate defaultAggregateState, + Action fold, + CancellationToken cancellationToken = default + ) + where TAggregate : class, IEventSourcedAggregate, new() + { + return _documentSession.Events.AggregateStreamAsync( + streamId, + version: StreamReadPosition.Start.Value, + null, + token: cancellationToken + ); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return _documentSession.SaveChangesAsync(cancellationToken); + } + + private IQueryable Filter(string streamId, long? version, DateTime? timestamp) + { + var query = _documentSession.Events.QueryAllRawEvents().AsQueryable(); + + query = query.Where(ev => ev.StreamKey == streamId); + + if (version.HasValue) + query = query.Where(ev => ev.Version >= version); + + if (timestamp.HasValue) + query = query.Where(ev => ev.Timestamp >= timestamp); + + return query; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenIdGenerator.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenIdGenerator.cs new file mode 100644 index 00000000..d0ced02d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenIdGenerator.cs @@ -0,0 +1,8 @@ +using Marten.Schema.Identity; + +namespace BuildingBlocks.Persistence.Marten; + +public static class MartenIdGenerator +{ + public static Guid New() => CombGuidIdGeneration.NewGuid(); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenOptions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenOptions.cs new file mode 100644 index 00000000..ea08c889 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/MartenOptions.cs @@ -0,0 +1,15 @@ +using Marten.Events.Daemon.Resiliency; + +namespace BuildingBlocks.Persistence.Marten; + +public class MartenOptions +{ + private const string DefaultSchema = "public"; + + public string ConnectionString { get; set; } = default!; + public string WriteModelSchema { get; set; } = DefaultSchema; + public string ReadModelSchema { get; set; } = DefaultSchema; + public bool ShouldRecreateDatabase { get; set; } + public DaemonMode DaemonMode { get; set; } = DaemonMode.Disabled; + public bool UseMetadata { get; set; } = true; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/IMartenEventsConsumer.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/IMartenEventsConsumer.cs new file mode 100644 index 00000000..0bf7768d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/IMartenEventsConsumer.cs @@ -0,0 +1,13 @@ +using Marten; +using Marten.Events; + +namespace BuildingBlocks.Persistence.Marten.Subscriptions; + +public interface IMartenEventsConsumer +{ + Task ConsumeAsync( + IDocumentOperations documentOperations, + IReadOnlyList streamActions, + CancellationToken cancellationToken = default + ); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenEventPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenEventPublisher.cs new file mode 100644 index 00000000..aedc580f --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenEventPublisher.cs @@ -0,0 +1,27 @@ +using Marten; +using Marten.Events; +using MediatR; + +namespace BuildingBlocks.Persistence.Marten.Subscriptions; + +public class MartenEventPublisher : IMartenEventsConsumer +{ + private readonly IMediator _mediator; + + public MartenEventPublisher(IMediator mediator) + { + _mediator = mediator; + } + + public async Task ConsumeAsync( + IDocumentOperations documentOperations, + IReadOnlyList streamActions, + CancellationToken cancellationToken = default + ) + { + foreach (var @event in streamActions.SelectMany(streamAction => streamAction.Events)) + { + await _mediator.Publish(@event, cancellationToken); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenSubscription.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenSubscription.cs new file mode 100644 index 00000000..ad38982d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/Subscriptions/MartenSubscription.cs @@ -0,0 +1,41 @@ +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Microsoft.Extensions.Logging; + +namespace BuildingBlocks.Persistence.Marten.Subscriptions; + +public class MartenSubscription : IProjection +{ + private readonly IEnumerable _consumers; + private readonly ILogger _logger; + + public MartenSubscription(IEnumerable consumers, ILogger logger) + { + _consumers = consumers; + _logger = logger; + } + + public void Apply(IDocumentOperations operations, IReadOnlyList streams) => + throw new NotImplementedException("Subscriptions should work only in the async scope"); + + public async Task ApplyAsync( + IDocumentOperations operations, + IReadOnlyList streams, + CancellationToken cancellation + ) + { + try + { + foreach (var consumer in _consumers) + { + await consumer.ConsumeAsync(operations, streams, cancellation); + } + } + catch (Exception exc) + { + _logger.LogError("Error while processing Marten Subscription: {ExceptionMessage}", exc.Message); + throw; + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/readme.md b/src/BuildingBlocks/BuildingBlocks.Persistence.Marten/readme.md new file mode 100644 index 00000000..e69de29b diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/Extensions/ServiceCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f6e301ca --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Persistence.Mongo; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.Serializers; + +namespace BuildingBlocks.Persistence.Mongo; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMongoDbContext( + this IServiceCollection services, + IConfiguration configuration, + Action? configurator = null, + params Assembly[] assembliesToScan + ) + where TContext : MongoDbContext, IMongoDbContext + { + services.AddValidatedOptions(nameof(MongoOptions)); + + var options = configuration.BindOptions(); + configurator?.Invoke(options); + + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + // Note: the serializers registrations and conventions should call just once whole of the application otherwise we get error + + // http://mongodb.github.io/mongo-csharp-driver/2.18/reference/bson/serialization/ + // http://mongodb.github.io/mongo-csharp-driver/2.18/reference/bson/guidserialization/guidrepresentationmode/guidrepresentationmode/ + // http://mongodb.github.io/mongo-csharp-driver/2.18/reference/bson/guidserialization/serializerchanges/guidserializerchanges/ + + // https://stackoverflow.com/questions/21386347/how-do-i-detect-whether-a-mongodb-serializer-is-already-registered + // https://stackoverflow.com/questions/16185262/what-is-new-way-of-setting-datetimeserializationoptions-defaults-in-mongodb-c-sh + + // we can write our own serializer register it with `RegisterSerializationProvider` and this serializer will work before default serializers. + // BsonSerializer.RegisterSerializationProvider(new LocalDateTimeSerializationProvider()); + // Or + BsonSerializer.RegisterSerializer(typeof(DateTime), DateTimeSerializer.LocalInstance); + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.CSharpLegacy)); + + RegisterConventions(); + + services.TryAddScoped(typeof(TContext)); + services.TryAddScoped(sp => sp.GetRequiredService()); + + services.AddMongoRepositories(assembliesToScan); + services.AddMongoUnitOfWork(assembliesToScan); + + return services; + } + + private static IServiceCollection AddMongoRepositories( + this IServiceCollection services, + params Assembly[] assembliesToScan + ) + { + var scanAssemblies = assembliesToScan.Any() ? assembliesToScan : new[] { Assembly.GetCallingAssembly() }; + services.Scan( + scan => + scan.FromAssemblies(scanAssemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IRepository<,>)), false) + .AsImplementedInterfaces() + .AsSelf() + .WithTransientLifetime() + ); + + return services; + } + + private static IServiceCollection AddMongoUnitOfWork( + this IServiceCollection services, + params Assembly[] assembliesToScan + ) + { + var scanAssemblies = assembliesToScan.Any() ? assembliesToScan : new[] { Assembly.GetCallingAssembly() }; + services.Scan( + scan => + scan.FromAssemblies(scanAssemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IMongoUnitOfWork<>)), false) + .AsImplementedInterfaces() + .AsSelf() + .WithTransientLifetime() + ); + + return services; + } + + private static void RegisterConventions() + { + ConventionRegistry.Register( + "conventions", + new ConventionPack + { + new CamelCaseElementNameConvention(), + new IgnoreExtraElementsConvention(true), + new EnumRepresentationConvention(BsonType.String), + new IgnoreIfDefaultConvention(false), + new ImmutablePocoConvention(), + }, + _ => true + ); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs index 36068815..e29d5a12 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs @@ -1,10 +1,6 @@ using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Abstractions.Persistence.Mongo; -using BuildingBlocks.Persistence.Mongo.Serializers; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Conventions; -using MongoDB.Bson.Serialization.Serializers; +using Humanizer; using MongoDB.Driver; namespace BuildingBlocks.Persistence.Mongo; @@ -15,7 +11,7 @@ public class MongoDbContext : IMongoDbContext, ITxDbContextExecution public IClientSessionHandle? Session { get; set; } public IMongoDatabase Database { get; } public IMongoClient MongoClient { get; } - protected readonly List> _commands; + protected readonly IList> _commands; public MongoDbContext(MongoOptions options) { @@ -29,7 +25,7 @@ public MongoDbContext(MongoOptions options) public IMongoCollection GetCollection(string? name = null) { - return Database.GetCollection(name ?? typeof(T).Name.ToLower()); + return Database.GetCollection(name ?? typeof(T).Name.Pluralize().Underscore()); } public void Dispose() @@ -44,19 +40,22 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de { var result = _commands.Count; + // Standalone servers do not support transactions. using (Session = await MongoClient.StartSessionAsync(cancellationToken: cancellationToken)) { - Session.StartTransaction(); - try { - var commandTasks = _commands.Select(c => c()); + Session.StartTransaction(); - await Task.WhenAll(commandTasks); + await SaveAction(); await Session.CommitTransactionAsync(cancellationToken); } - catch (System.Exception ex) + catch (NotSupportedException notSupportedException) + { + await SaveAction(); + } + catch (Exception ex) { await Session.AbortTransactionAsync(cancellationToken); _commands.Clear(); @@ -65,9 +64,17 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de } _commands.Clear(); + return result; } + private async Task SaveAction() + { + var commandTasks = _commands.Select(c => c()); + + await Task.WhenAll(commandTasks); + } + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) { Session = await MongoClient.StartSessionAsync(cancellationToken: cancellationToken); diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoRepositoryBase.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoRepositoryBase.cs new file mode 100644 index 00000000..3b00fc68 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoRepositoryBase.cs @@ -0,0 +1,190 @@ +using System.Linq.Expressions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Extensions; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Sieve.Services; + +namespace BuildingBlocks.Persistence.Mongo; + +public class MongoRepositoryBase : IRepository + where TEntity : class, IHaveIdentity + where TDbContext : MongoDbContext +{ + private readonly TDbContext _context; + private readonly ISieveProcessor _sieveProcessor; + protected readonly IMongoCollection DbSet; + + public MongoRepositoryBase(TDbContext context, ISieveProcessor sieveProcessor) + { + _context = context; + _sieveProcessor = sieveProcessor; + DbSet = _context.GetCollection(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + public async Task FindByIdAsync(TId id, CancellationToken cancellationToken = default) + { + var filter = Builders.Filter.Eq(x => x.Id, id); + return await DbSet.Find(filter).FirstOrDefaultAsync(cancellationToken); + } + + public Task FindOneAsync( + Expression> predicate, + CancellationToken cancellationToken = default + ) + { + return DbSet.Find(predicate).SingleOrDefaultAsync(cancellationToken: cancellationToken)!; + } + + public async Task> FindAsync( + Expression> predicate, + CancellationToken cancellationToken = default + ) + { + return await DbSet.Find(predicate).ToListAsync(cancellationToken: cancellationToken)!; + } + + public async Task AnyAsync( + Expression> predicate, + CancellationToken cancellationToken = default + ) + { + var filter = Builders.Filter.Where(predicate); + var count = await DbSet.CountDocumentsAsync(filter, null, cancellationToken); + return count > 0; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await DbSet.AsQueryable().ToListAsync(cancellationToken); + } + + public IAsyncEnumerable ProjectBy( + IConfigurationProvider configuration, + Expression>? predicate = null, + Expression>? sortExpression = null, + CancellationToken cancellationToken = default + ) + { + IMongoQueryable query = DbSet.AsQueryable(); + if (predicate is not null) + { + query = query.Where(predicate); + } + + if (sortExpression is not null) + { + query = query.OrderByDescending(sortExpression); + } + + return query.ProjectTo(configuration).ToAsyncEnumerable(); + } + + public async Task> GetByPageFilter( + IPageRequest pageRequest, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default + ) + { + return await DbSet + .AsQueryable() + .ApplyPagingAsync(pageRequest, _sieveProcessor, predicate, sortExpression, cancellationToken); + } + + public async Task> GetByPageFilter( + IPageRequest pageRequest, + IConfigurationProvider configuration, + Expression> sortExpression, + Expression>? predicate = null, + CancellationToken cancellationToken = default + ) + where TResult : class + { + return await DbSet + .AsQueryable() + .ApplyPagingAsync( + pageRequest, + _sieveProcessor, + configuration, + predicate, + sortExpression, + cancellationToken + ); + } + + public Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + await DbSet.InsertOneAsync(entity, null, cancellationToken); + }); + + return Task.FromResult(entity); + } + + public Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + var filter = Builders.Filter.Eq(x => x.Id, entity.Id); + await DbSet.ReplaceOneAsync(filter, entity, cancellationToken: cancellationToken); + }); + + return Task.FromResult(entity); + } + + public Task DeleteRangeAsync(IReadOnlyList entities, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + var idsToDelete = entities.Select(x => x.Id).ToList(); + var filter = Builders.Filter.In(x => x.Id, idsToDelete); + await DbSet.DeleteManyAsync(filter, cancellationToken); + }); + + return Task.CompletedTask; + } + + public Task DeleteAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + await DbSet.DeleteOneAsync(predicate, cancellationToken); + }); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + var filter = Builders.Filter.Eq(x => x.Id, entity.Id); + await DbSet.DeleteOneAsync(filter, cancellationToken); + }); + + return Task.CompletedTask; + } + + public Task DeleteByIdAsync(TId id, CancellationToken cancellationToken = default) + { + _context.AddCommand(async () => + { + var filter = Builders.Filter.Eq(x => x.Id, id); + await DbSet.DeleteOneAsync(filter, cancellationToken); + }); + + return Task.CompletedTask; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoUnitOfWork.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoUnitOfWork.cs index dfd0b463..dbb6c443 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoUnitOfWork.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoUnitOfWork.cs @@ -30,5 +30,8 @@ public Task CommitTransactionAsync(CancellationToken cancellationToken = default return Context.CommitTransactionAsync(cancellationToken); } - public void Dispose() => Context.Dispose(); + public void Dispose() + { + GC.SuppressFinalize(this); + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.CircuitBreaker.cs b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.CircuitBreaker.cs index d26660f8..0693450b 100644 --- a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.CircuitBreaker.cs +++ b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.CircuitBreaker.cs @@ -1,6 +1,4 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; using BuildingBlocks.Resiliency.CircuitBreaker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -16,7 +14,7 @@ public static IHttpClientBuilder AddCircuitBreakerHandler(this IHttpClientBuilde { var options = sp.GetRequiredService().BindOptions(nameof(PolicyOptions)); - Guard.Against.Null(options, nameof(options)); + options.NotBeNull(); var loggerFactory = sp.GetRequiredService(); var circuitBreakerLogger = loggerFactory.CreateLogger("PollyHttpCircuitBreakerPoliciesLogger"); diff --git a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.Retry.cs b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.Retry.cs index 13dc3a75..100c3cd9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.Retry.cs +++ b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/HttpClientBuilderExtensions.Retry.cs @@ -1,6 +1,4 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; using BuildingBlocks.Resiliency.Retry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -17,7 +15,7 @@ public static IHttpClientBuilder AddRetryPolicyHandler(this IHttpClientBuilder h { var options = sp.GetRequiredService().BindOptions(nameof(PolicyOptions)); - Guard.Against.Null(options, nameof(options)); + options.NotBeNull(); var loggerFactory = sp.GetRequiredService(); var retryLogger = loggerFactory.CreateLogger("PollyHttpRetryPoliciesLogger"); diff --git a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/ServiceCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/ServiceCollectionExtensions.cs index 0ea39815..dccd6321 100644 --- a/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/ServiceCollectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Resiliency/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using BuildingBlocks.Resiliency.Fallback; using BuildingBlocks.Resiliency.Retry; using MediatR; +using Microsoft.Extensions.DependencyInjection.Extensions; using Scrutor; namespace BuildingBlocks.Resiliency.Extensions; @@ -13,7 +14,7 @@ public static IServiceCollection AddMediaterRetryPolicy( IReadOnlyList assemblies ) { - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RetryBehavior<,>)); + services.TryAddTransient(typeof(IPipelineBehavior<,>), typeof(RetryBehavior<,>)); services.Scan( scan => @@ -32,7 +33,7 @@ public static IServiceCollection AddMediaterFallbackPolicy( IReadOnlyList assemblies ) { - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(FallbackBehavior<,>)); + services.TryAddTransient(typeof(IPipelineBehavior<,>), typeof(FallbackBehavior<,>)); services.Scan( scan => diff --git a/src/BuildingBlocks/BuildingBlocks.Resiliency/PolicyOptions.cs b/src/BuildingBlocks/BuildingBlocks.Resiliency/PolicyOptions.cs index b2765ade..92b62cae 100644 --- a/src/BuildingBlocks/BuildingBlocks.Resiliency/PolicyOptions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Resiliency/PolicyOptions.cs @@ -7,7 +7,7 @@ namespace BuildingBlocks.Resiliency; // Ref: https://anthonygiretti.com/2019/03/26/best-practices-with-httpclient-and-retry-policies-with-polly-in-net-core-2-part-2/ public class PolicyOptions : ICircuitBreakerPolicyOptions, IRetryPolicyOptions, ITimeoutPolicyOptions { - public int RetryCount { get; set; } - public int BreakDuration { get; set; } - public int TimeOutDuration { get; set; } + public int RetryCount { get; set; } = 3; + public int BreakDuration { get; set; } = 30; + public int TimeOutDuration { get; set; } = 15; } diff --git a/src/BuildingBlocks/BuildingBlocks.Security/Extensions/ApiKeyExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Security/Extensions/ApiKeyExtensions.cs index dae68c7a..49c6b9db 100644 --- a/src/BuildingBlocks/BuildingBlocks.Security/Extensions/ApiKeyExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Security/Extensions/ApiKeyExtensions.cs @@ -1,6 +1,7 @@ using BuildingBlocks.Security.ApiKey; using BuildingBlocks.Security.ApiKey.Authorization; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace BuildingBlocks.Security.Extensions; @@ -29,11 +30,11 @@ public static IServiceCollection AddCustomApiKeyAuthentication(this IServiceColl ); }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/BuildingBlocks/BuildingBlocks.Security/Extensions/JwtExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Security/Extensions/JwtExtensions.cs index b0968607..7a2bbc4a 100644 --- a/src/BuildingBlocks/BuildingBlocks.Security/Extensions/JwtExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Security/Extensions/JwtExtensions.cs @@ -1,15 +1,15 @@ using System.IdentityModel.Tokens.Jwt; -using System.Net; using System.Text; -using Ardalis.GuardClauses; using BuildingBlocks.Core.Exception.Types; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Security.Jwt; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.IdentityModel.Tokens; namespace BuildingBlocks.Security.Extensions; @@ -19,20 +19,24 @@ public static class Extensions public static AuthenticationBuilder AddCustomJwtAuthentication( this IServiceCollection services, IConfiguration configuration, - Action? optionConfigurator = null + Action? configurator = null ) { // https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/415 // https://mderriey.com/2019/06/23/where-are-my-jwt-claims/ // https://leastprivilege.com/2017/11/15/missing-claims-in-the-asp-net-core-2-openid-connect-handler/ // https://stackoverflow.com/a/50012477/581476 + // to compatibility with new versions of claim names standard JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); - AddJwtServices(services, configuration, optionConfigurator); + var jwtOptions = configuration.BindOptions(); + configurator?.Invoke(jwtOptions); - var jwtOptions = configuration.BindOptions(nameof(JwtOptions)); - Guard.Against.Null(jwtOptions, nameof(jwtOptions)); + // add option to the dependency injection + services.AddValidationOptions(opt => configurator?.Invoke(opt)); + + services.TryAddTransient(); // https://docs.microsoft.com/en-us/aspnet/core/security/authentication // https://learn.microsoft.com/en-us/aspnet/core/security/authorization/limitingidentitybyscheme?view=aspnetcore-6.0#use-multiple-authentication-schemes @@ -58,6 +62,8 @@ public static AuthenticationBuilder AddCustomJwtAuthentication( ValidIssuer = jwtOptions.Issuer, ValidAudience = jwtOptions.Audience, SaveSigninToken = true, + // default skew is 5 minutes, + // The ClockSkew property allows you to specify the amount of leeway to account for any differences in clock times between the token issuer and the token validation server. This property defines the maximum amount of time (in seconds) by which the token's expiration or not-before time can differ from the system clock on the validation server. ClockSkew = TimeSpan.Zero, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)) }; @@ -73,7 +79,7 @@ public static AuthenticationBuilder AddCustomJwtAuthentication( throw new IdentityException( context.Exception.Message, - statusCode: HttpStatusCode.InternalServerError + statusCode: StatusCodes.Status500InternalServerError ); }, OnChallenge = context => @@ -93,47 +99,18 @@ public static AuthenticationBuilder AddCustomJwtAuthentication( }); } - public static IServiceCollection AddJwtServices( - this IServiceCollection services, - IConfiguration configuration, - Action? optionConfigurator = null - ) - { - var jwtOptions = configuration.BindOptions(nameof(JwtOptions)); - Guard.Against.Null(jwtOptions, nameof(jwtOptions)); - - optionConfigurator?.Invoke(jwtOptions); - - if (optionConfigurator is { }) - { - services.Configure(nameof(JwtOptions), optionConfigurator); - } - else - { - services - .AddOptions() - .Bind(configuration.GetSection(nameof(JwtOptions))) - .ValidateDataAnnotations(); - } - - services.AddTransient(); - - return services; - } - public static IServiceCollection AddCustomAuthorization( this IServiceCollection services, IList? claimPolicies = null, - IList? rolePolicies = null + IList? rolePolicies = null, + string scheme = JwtBearerDefaults.AuthenticationScheme ) { services.AddAuthorization(authorizationOptions => { // https://docs.microsoft.com/en-us/aspnet/core/security/authorization/limitingidentitybyscheme // https://andrewlock.net/setting-global-authorization-policies-using-the-defaultpolicy-and-the-fallbackpolicy-in-aspnet-core-3/ - var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder( - JwtBearerDefaults.AuthenticationScheme - ); + var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(scheme); defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser(); authorizationOptions.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build(); @@ -146,7 +123,7 @@ public static IServiceCollection AddCustomAuthorization( policy.Name, x => { - x.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme); + x.AuthenticationSchemes.Add(scheme); foreach (var policyClaim in policy.Claims) { x.RequireClaim(policyClaim.Type, policyClaim.Value); @@ -165,7 +142,7 @@ public static IServiceCollection AddCustomAuthorization( rolePolicy.Name, x => { - x.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme); + x.AuthenticationSchemes.Add(scheme); x.RequireRole(rolePolicy.Roles); } ); @@ -179,7 +156,7 @@ public static IServiceCollection AddCustomAuthorization( public static void AddExternalLogins(this IServiceCollection services, IConfiguration configuration) { var jwtOptions = configuration.BindOptions(nameof(JwtOptions)); - Guard.Against.Null(jwtOptions, nameof(jwtOptions)); + jwtOptions.NotBeNull(); if (jwtOptions.GoogleLoginConfigs is { }) { diff --git a/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtOptions.cs b/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtOptions.cs index feb137c8..f4808087 100644 --- a/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtOptions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtOptions.cs @@ -8,7 +8,7 @@ public class JwtOptions public string? Audience { get; set; } public double TokenLifeTimeSecond { get; set; } = 300; - public bool CheckRevokedAccessTokens { get; set; } = false; + public bool CheckRevokedAccessTokens { get; set; } public GoogleExternalLogin? GoogleLoginConfigs { get; set; } public class GoogleExternalLogin diff --git a/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtService.cs b/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtService.cs index 08971e6c..d47b2748 100644 --- a/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtService.cs +++ b/src/BuildingBlocks/BuildingBlocks.Security/Jwt/JwtService.cs @@ -2,7 +2,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Ardalis.GuardClauses; +using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Utils; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -80,30 +80,36 @@ public GenerateTokenResult GenerateJwtToken( if (usersClaims?.Any() is true) jwtClaims = jwtClaims.Union(usersClaims).ToList(); - Guard.Against.NullOrEmpty(_jwtOptions.SecretKey, nameof(_jwtOptions.SecretKey)); + _jwtOptions.SecretKey.NotBeNullOrWhiteSpace(); SymmetricSecurityKey signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey)); SigningCredentials signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); var expireTime = now.AddSeconds(_jwtOptions.TokenLifeTimeSecond == 0 ? 300 : _jwtOptions.TokenLifeTimeSecond); - var jwt = new JwtSecurityToken( - _jwtOptions.Issuer, - _jwtOptions.Audience, - notBefore: now, - claims: jwtClaims, - expires: expireTime, - signingCredentials: signingCredentials - ); - var token = new JwtSecurityTokenHandler().WriteToken(jwt); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new Claim[] { new(ClaimTypes.Name, userId) }), + Expires = expireTime, + SigningCredentials = signingCredentials, + Claims = jwtClaims.ConvertClaimsToDictionary(), + Issuer = _jwtOptions.Issuer, + Audience = _jwtOptions.Issuer, + NotBefore = now + }; + + JwtSecurityTokenHandler jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + jwtSecurityTokenHandler.OutboundClaimTypeMap.Clear(); + var securityToken = jwtSecurityTokenHandler.CreateToken(tokenDescriptor); + var token = jwtSecurityTokenHandler.WriteToken(securityToken); return new GenerateTokenResult(token, expireTime); } public ClaimsPrincipal? GetPrincipalFromToken(string token) { - Guard.Against.NullOrEmpty(token, nameof(token)); - Guard.Against.NullOrEmpty(_jwtOptions.SecretKey, nameof(_jwtOptions.SecretKey)); + token.NotBeNullOrWhiteSpace(); + _jwtOptions.SecretKey.NotBeNullOrWhiteSpace(); TokenValidationParameters tokenValidationParameters = new TokenValidationParameters { @@ -133,3 +139,11 @@ out SecurityToken securityToken return principal; } } + +public static class JwtHelper +{ + public static IDictionary ConvertClaimsToDictionary(this IList claims) + { + return claims.ToDictionary(claim => claim.Type, claim => (object)claim.Value); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Swagger/Extensions.cs b/src/BuildingBlocks/BuildingBlocks.Swagger/Extensions.cs index 83fc6f50..6151fc1f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Swagger/Extensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Swagger/Extensions.cs @@ -1,8 +1,7 @@ -using System.Reflection; -using BuildingBlocks.Core.Web.Extenions.ServiceCollection; +using BuildingBlocks.Core.Extensions.ServiceCollection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -14,24 +13,19 @@ public static class Extensions { // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/README.md // https://github.com/dotnet/aspnet-api-versioning/tree/88323136a97a59fcee24517a514c1a445530c7e2/examples/AspNetCore/WebApi/MinimalOpenApiExample - public static WebApplicationBuilder AddCustomSwagger( - this WebApplicationBuilder builder, - params Assembly[] assemblies - ) + public static WebApplicationBuilder AddCustomSwagger(this WebApplicationBuilder builder) { - builder.Services.AddCustomSwagger(assemblies); + builder.Services.AddCustomSwagger(); return builder; } - public static IServiceCollection AddCustomSwagger(this IServiceCollection services, params Assembly[] assemblies) + public static IServiceCollection AddCustomSwagger(this IServiceCollection services) { - var scanAssemblies = assemblies.Any() ? assemblies : new[] { Assembly.GetExecutingAssembly() }; - // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi services.AddEndpointsApiExplorer(); - services.AddTransient, ConfigureSwaggerOptions>(); + services.TryAddTransient, ConfigureSwaggerOptions>(); services.AddValidatedOptions(); services.AddSwaggerGen(options => @@ -45,17 +39,10 @@ public static IServiceCollection AddCustomSwagger(this IServiceCollection servic options.OperationFilter(); options.OperationFilter(); - foreach (var assembly in scanAssemblies) - { - var xmlFile = XmlCommentsFilePath(assembly); - if (File.Exists(xmlFile)) - options.IncludeXmlComments(xmlFile); - } - // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements // https://swagger.io/docs/specification/authentication/ // https://medium.com/@niteshsinghal85/assign-specific-authorization-scheme-to-endpoint-in-swagger-ui-in-net-core-cd84d2a2ebd7 - var bearerScheme = new OpenApiSecurityScheme() + var bearerScheme = new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, Name = JwtBearerDefaults.AuthenticationScheme, @@ -94,13 +81,6 @@ public static IServiceCollection AddCustomSwagger(this IServiceCollection servic options.EnableAnnotations(); }); - static string XmlCommentsFilePath(Assembly assembly) - { - var basePath = Path.GetDirectoryName(assembly.Location); - var fileName = assembly.GetName().Name + ".xml"; - return Path.Combine(basePath, fileName); - } - return services; } diff --git a/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/RegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/RegistrationExtensions.cs new file mode 100644 index 00000000..5a5c1a6b --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/RegistrationExtensions.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using FluentValidation; +using FluentValidation.Results; +using Scrutor; + +namespace BuildingBlocks.Validation.Extensions; + +public static class RegistrationExtensions +{ + public static IServiceCollection AddCustomValidators(this IServiceCollection services, Assembly assembly) + { + // https://docs.fluentvalidation.net/en/latest/di.html + // I have some problem with registering IQuery validators with this + // services.AddValidatorsFromAssembly(assembly); + services.Scan( + scan => + scan.FromAssemblies(assembly) + .AddClasses(classes => classes.AssignableTo(typeof(IValidator<>))) + .UsingRegistrationStrategy(RegistrationStrategy.Skip) + .AsImplementedInterfaces() + .WithLifetime(ServiceLifetime.Transient) + ); + + return services; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/ValidatorExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/ValidatorExtensions.cs new file mode 100644 index 00000000..503f72ba --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Validation/Extensions/ValidatorExtensions.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using FluentValidation.Results; + +namespace BuildingBlocks.Validation.Extensions; + +public static class ValidatorExtensions +{ + // https://www.jerriepelser.com/blog/validation-response-aspnet-core-webapi + public static async Task HandleValidationAsync( + this IValidator validator, + TRequest request, + CancellationToken cancellationToken = default + ) + { + var validationResult = await validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false); + if (!validationResult.IsValid) + throw new ValidationException(validationResult.ToValidationResultModel().Message); + + return request; + } + + public static TRequest HandleValidation(this IValidator validator, TRequest request) + { + var validationResult = validator.Validate(request); + if (!validationResult.IsValid) + throw new ValidationException(validationResult.ToValidationResultModel().Message); + + return request; + } + + private static ValidationResultModel ToValidationResultModel(this ValidationResult? validationResult) + { + return new ValidationResultModel(validationResult); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Validation/RequestValidationBehavior.cs b/src/BuildingBlocks/BuildingBlocks.Validation/RequestValidationBehavior.cs index e67320cc..3a5f9483 100644 --- a/src/BuildingBlocks/BuildingBlocks.Validation/RequestValidationBehavior.cs +++ b/src/BuildingBlocks/BuildingBlocks.Validation/RequestValidationBehavior.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using BuildingBlocks.Validation.Extensions; using FluentValidation; using MediatR; using Microsoft.Extensions.Logging; @@ -6,20 +7,19 @@ namespace BuildingBlocks.Validation; public class RequestValidationBehavior : IPipelineBehavior - where TRequest : notnull, IRequest - where TResponse : notnull + where TRequest : IRequest + where TResponse : class { private readonly ILogger> _logger; private readonly IServiceProvider _serviceProvider; - private IValidator _validator; public RequestValidationBehavior( IServiceProvider serviceProvider, ILogger> logger ) { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _serviceProvider = serviceProvider; + _logger = logger; } public async Task Handle( @@ -28,12 +28,12 @@ public async Task Handle( CancellationToken cancellationToken ) { - _validator = _serviceProvider.GetService>()!; - if (_validator is null) + var validator = _serviceProvider.GetService>()!; + if (validator is null) return await next(); _logger.LogInformation( - "[{Prefix}] Handle request={X-RequestData} and response={X-ResponseData}", + "[{Prefix}] Handle request={RequestData} and response={ResponseData}", nameof(RequestValidationBehavior), typeof(TRequest).Name, typeof(TResponse).Name @@ -45,7 +45,7 @@ CancellationToken cancellationToken JsonSerializer.Serialize(request) ); - await _validator.HandleValidationAsync(request, cancellationToken); + await validator.HandleValidationAsync(request, cancellationToken); var response = await next(); @@ -55,8 +55,8 @@ CancellationToken cancellationToken } public class StreamRequestValidationBehavior : IStreamPipelineBehavior - where TRequest : notnull, IStreamRequest - where TResponse : notnull + where TRequest : IStreamRequest + where TResponse : class { private readonly ILogger> _logger; private readonly IServiceProvider _serviceProvider; @@ -71,7 +71,7 @@ ILogger> logger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public IAsyncEnumerable Handle( + public async IAsyncEnumerable Handle( TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken @@ -79,10 +79,17 @@ CancellationToken cancellationToken { _validator = _serviceProvider.GetService>()!; if (_validator is null) - return next(); + { + await foreach (var response in next().WithCancellation(cancellationToken)) + { + yield return response; + } + + yield break; + } _logger.LogInformation( - "[{Prefix}] Handle request={X-RequestData} and response={X-ResponseData}", + "[{Prefix}] Handle request={RequestData} and response={ResponseData}", nameof(StreamRequestValidationBehavior), typeof(TRequest).Name, typeof(TResponse).Name @@ -96,9 +103,10 @@ CancellationToken cancellationToken _validator.HandleValidation(request); - var response = next(); - - _logger.LogInformation("Handled {FullName}", typeof(TRequest).FullName); - return response; + await foreach (var response in next().WithCancellation(cancellationToken)) + { + yield return response; + _logger.LogInformation("Handled {FullName}", typeof(TRequest).FullName); + } } } diff --git a/src/BuildingBlocks/BuildingBlocks.Validation/ValidationResultModel.cs b/src/BuildingBlocks/BuildingBlocks.Validation/ValidationResultModel.cs index 33bc0b98..64e64798 100644 --- a/src/BuildingBlocks/BuildingBlocks.Validation/ValidationResultModel.cs +++ b/src/BuildingBlocks/BuildingBlocks.Validation/ValidationResultModel.cs @@ -1,6 +1,7 @@ using System.Net; -using System.Text.Json; using FluentValidation.Results; +using Newtonsoft.Json; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace BuildingBlocks.Validation; @@ -11,10 +12,11 @@ public ValidationResultModel(ValidationResult? validationResult = null) Errors = validationResult?.Errors .Select(error => new ValidationError(error.PropertyName, error.ErrorMessage)) .ToList(); + Message = JsonConvert.SerializeObject(Errors); } public int StatusCode { get; set; } = (int)HttpStatusCode.BadRequest; - public string Message { get; set; } = "Validation Failed."; + public string Message { get; set; } public IList? Errors { get; } diff --git a/src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj b/src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj index 3f54aa0e..5563eb50 100644 --- a/src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj @@ -15,10 +15,10 @@ - + diff --git a/src/BuildingBlocks/BuildingBlocks.Web/CorsOptions.cs b/src/BuildingBlocks/BuildingBlocks.Web/CorsOptions.cs new file mode 100644 index 00000000..7b698f68 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/CorsOptions.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Web; + +public class CorsOptions +{ + public IEnumerable AllowedUrls { get; set; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/CorsExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/CorsExtensions.cs new file mode 100644 index 00000000..801d6036 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/CorsExtensions.cs @@ -0,0 +1,39 @@ +using BuildingBlocks.Core.Extensions.ServiceCollection; +using Microsoft.AspNetCore.Builder; + +namespace BuildingBlocks.Web.Extensions; + +// https://learn.microsoft.com/en-us/aspnet/core/security/cors +public static class Extensions +{ + private const string CorsPolicyName = "AllowSpecificOrigins"; + + public static WebApplicationBuilder AddCustomCors(this WebApplicationBuilder builder) + { + builder.Services.AddValidatedOptions(); + + builder.Services.AddCors(); + + return builder; + } + + public static WebApplication UseCustomCors(this WebApplication app) + { + var options = app.Services.GetService(); + app.UseCors(p => + { + if (options?.AllowedUrls is { } && options.AllowedUrls.Any()) + { + p.WithOrigins(options.AllowedUrls.ToArray()); + } + else + { + p.AllowAnyOrigin(); + } + + p.AllowAnyMethod(); + p.AllowAnyHeader(); + }); + return app; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HeaderDictionaryExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HeaderDictionaryExtensions.cs new file mode 100644 index 00000000..4b8989ff --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HeaderDictionaryExtensions.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Web.Extensions; + +// https://khalidabuhakmeh.com/read-and-convert-querycollection-values-in-aspnet +public static class HeaderDictionaryExtensions +{ + public static IEnumerable All(this IHeaderDictionary collection, string key) + { + var values = new List(); + + if (collection.TryGetValue(key, out var results)) + { + foreach (var s in results) + { + try + { + var result = (T)Convert.ChangeType(s, typeof(T)); + values.Add(result); + } + catch + { + // conversion failed + // skip value + } + } + } + + // return an array with at least one + return values; + } + + public static T Get( + this IHeaderDictionary collection, + string key, + T @default = default, + ParameterPick option = ParameterPick.First + ) + { + var values = All(collection, key).ToList(); + var value = @default; + + if (values.Any()) + { + value = option switch + { + ParameterPick.First => values.FirstOrDefault(), + ParameterPick.Last => values.LastOrDefault(), + _ => value + }; + } + + return value ?? @default; + } +} + +public enum ParameterPick +{ + First, + Last +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HostEnvironmentExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HostEnvironmentExtensions.cs new file mode 100644 index 00000000..1c27e7fa --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HostEnvironmentExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Hosting; + +namespace BuildingBlocks.Web.Extensions; + +public static class HostEnvironmentExtensions +{ + public static bool IsTest(this IHostEnvironment env) => env.IsEnvironment("test"); + + public static bool IsDocker(this IHostEnvironment env) => env.IsEnvironment("docker"); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpClientExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpClientExtensions.cs new file mode 100644 index 00000000..a7885441 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpClientExtensions.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Json; + +namespace BuildingBlocks.Web.Extensions; + +public static class HttpClientExtensions +{ + public static async Task PostAsJsonAsync( + this HttpClient httpClient, + string requestUri, + TRequest request, + CancellationToken cancellationToken = default + ) + { + var responseMessage = await httpClient.PostAsJsonAsync( + requestUri, + request, + cancellationToken: cancellationToken + ); + + var result = await responseMessage.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + return result; + } + + public static async Task PutAsJsonAsync( + this HttpClient httpClient, + string requestUri, + TRequest request, + CancellationToken cancellationToken = default + ) + { + var responseMessage = await httpClient.PutAsJsonAsync( + requestUri, + request, + cancellationToken: cancellationToken + ); + + var result = await responseMessage.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + return result; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpContextExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpContextExtensions.cs new file mode 100644 index 00000000..01547016 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpContextExtensions.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using BuildingBlocks.Abstractions.Core.Paging; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace BuildingBlocks.Web.Extensions; + +public static class HttpContextExtensions +{ + public static string? GetTraceId(this IHttpContextAccessor httpContextAccessor) + { + return Activity.Current?.TraceId.ToString() ?? httpContextAccessor?.HttpContext?.TraceIdentifier; + } + + public static string GetCorrelationId(this HttpContext httpContext) + { + httpContext.Request.Headers.TryGetValue("Cko-Correlation-InternalCommandId", out StringValues correlationId); + return correlationId.FirstOrDefault() ?? httpContext.TraceIdentifier; + } + + public static TResult? ExtractXQueryObjectFromHeader(this HttpContext httpContext, string query) + where TResult : IPageRequest, new() + { + var queryModel = new TResult(); + if (!(string.IsNullOrEmpty(query) || query == "{}")) + { + queryModel = JsonConvert.DeserializeObject(query); + } + + httpContext?.Response.Headers.Add( + "x-query", + JsonConvert.SerializeObject( + queryModel, + new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() } + ) + ); + + return queryModel; + } + + public static string? GetUserHostAddress(this HttpContext context) + { + return context.Request.Headers["X-Forwarded-For"].FirstOrDefault() + ?? context.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpQueryExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpQueryExtensions.cs new file mode 100644 index 00000000..45ef12c1 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpQueryExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; + +namespace BuildingBlocks.Web.Extensions; + +// ref: https://khalidabuhakmeh.com/adding-experimental-http-methods-to-aspnet-core +public static class HttpQueryExtensions +{ + public static IEndpointConventionBuilder MapQuery( + this IEndpointRouteBuilder endpoints, + string pattern, + Func requestDelegate + ) + { + return endpoints.MapMethods(pattern, new[] { "QUERY" }, requestDelegate); + } + + public static IEndpointConventionBuilder MapQuery( + this IEndpointRouteBuilder endpoints, + string pattern, + RequestDelegate requestDelegate + ) + { + return endpoints.MapMethods(pattern, new[] { "QUERY" }, requestDelegate); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpResponseMessageExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpResponseMessageExtensions.cs new file mode 100644 index 00000000..8a71782b --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/HttpResponseMessageExtensions.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace BuildingBlocks.Web.Extensions; + +public static class HttpResponseMessageExtensions +{ + /// + /// Throws an exception if the property for the HTTP response is and read exception detail from response content - default EnsureSuccessStatusCode returns HttpRequestException with no response detail exception + /// Ref: https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + /// + /// HttpResponseMessage. + public static async Task EnsureSuccessStatusCodeWithDetailAsync(this HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var content = await response.Content.ReadAsStringAsync(); + + throw new HttpResponseException(content, (int)response.StatusCode); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/QueryCollectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/QueryCollectionExtensions.cs new file mode 100644 index 00000000..d5af1b33 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/QueryCollectionExtensions.cs @@ -0,0 +1,96 @@ +using System.Collections; +using BuildingBlocks.Core.Types.Extensions; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; + +namespace BuildingBlocks.Web.Extensions; + +// https://khalidabuhakmeh.com/read-and-convert-querycollection-values-in-aspnet +public static class QueryCollectionExtensions +{ + public static IEnumerable All(this IQueryCollection collection, string key) + { + List values = new List(); + if (collection.TryGetValue(key, out var results)) + { + foreach (var s in results) + { + try + { + var result = (T)Convert.ChangeType(s, typeof(T)); + values.Add(result); + } + catch (System.Exception) + { + // conversion failed + // skip value + } + } + } + + return values; + } + + public static T Get( + this IQueryCollection collection, + string key, + T @default = default, + ParameterPick option = ParameterPick.First + ) + { + var values = All(collection, key); + var value = @default; + + if (values.Any()) + { + value = option switch + { + ParameterPick.First => values.FirstOrDefault(), + ParameterPick.Last => values.LastOrDefault(), + _ => value + }; + } + + return value ?? @default; + } + + public static T GetCollection(this IQueryCollection collection, string key, T @default = default) + where T : IEnumerable + { + var type = typeof(T).GetGenericArguments()[0]; + var listType = typeof(List<>); + var constructedListType = listType.MakeGenericType(type); + dynamic values = Activator.CreateInstance(constructedListType); + + if (collection.TryGetValue(key, out var results)) + { + foreach (var s in results) + { + try + { + if (s.IsValidJson()) + { + dynamic result = JsonConvert.DeserializeObject(s, type); + values.Add(result); + } + else + { + dynamic result = Convert.ChangeType(s, type); + values.Add(result); + } + } + catch (System.Exception) + { + // conversion failed + // skip value + } + } + } + else + { + return @default; + } + + return values; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/VersioningExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/VersioningExtensions.cs index 4be500fa..cbd2c4c1 100644 --- a/src/BuildingBlocks/BuildingBlocks.Web/Extensions/VersioningExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Web/Extensions/VersioningExtensions.cs @@ -29,8 +29,11 @@ public static WebApplicationBuilder AddCustomVersioning( // existing service introduces a breaking change. Conceptually, clients in this situation are // bound to some API version of a service, but they don't know what it is and never explicit request it. options.AssumeDefaultVersionWhenUnspecified = true; + + // the default value of `DefaultApiVersion` is `ApiVersion(1, new int?(0)` options.DefaultApiVersion = new ApiVersion(1, 0); + // default `ApiVersionReader` is combine of both `QueryStringApiVersionReader` and `UrlSegmentApiVersionReader` // Defines how an API version is read from the current HTTP request options.ApiVersionReader = ApiVersionReader.Combine( new HeaderApiVersionReader("api-version"), diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareExtensions.cs new file mode 100644 index 00000000..fa93c77a --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; + +public static class CaptureExceptionMiddlewareExtensions +{ + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + public static IApplicationBuilder UseCaptureException(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + app.Properties["analysis.NextMiddlewareName"] = "Shared.Web.Middlewares.CaptureExceptionMiddleware"; + return app.UseMiddleware(); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareImp.cs b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareImp.cs new file mode 100644 index 00000000..3984d9c2 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/CaptureExceptionMiddleware/CaptureExceptionMiddlewareImp.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/write +public class CaptureExceptionMiddlewareImp +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public CaptureExceptionMiddlewareImp(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception e) + { + CaptureException(e, context); + throw; + } + } + + private static void CaptureException(Exception exception, HttpContext context) + { + ExceptionHandlerFeature instance = new ExceptionHandlerFeature + { + Path = context.Request.Path, + Error = exception + }; + context.Features.Set((IExceptionHandlerPathFeature)instance); + context.Features.Set((IExceptionHandlerFeature)instance); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddleware.cs b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddleware.cs new file mode 100644 index 00000000..fc92ff92 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddleware.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Web.Extensions; +using Microsoft.AspNetCore.Http; +using Serilog.Context; + +namespace BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; + +public class RequestLogContextMiddlewareImp +{ + private readonly RequestDelegate _next; + + public RequestLogContextMiddlewareImp(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + using (LogContext.PushProperty("CorrelationId", context.GetCorrelationId())) + { + await _next.Invoke(context); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddlewareExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddlewareExtensions.cs new file mode 100644 index 00000000..d2656298 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Middlewares/RequestLogContextMiddleware/RequestLogContextMiddlewareExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; + +public static class RequestLogContextMiddlewareExtensions +{ + public static IApplicationBuilder UseRequestLogContextMiddleware(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointConventionBuilderExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..29a0d72d --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointConventionBuilderExtensions.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Web.Minimal.Extensions; + +public static class EndpointConventionBuilderExtensions +{ + public static RouteHandlerBuilder Produces( + this RouteHandlerBuilder builder, + string description, + int statusCode, + Type? responseType = null, + string? contentType = null, + params string[] additionalContentTypes + ) + { + // WithOpenApi should placed before versioning and other things - this fixed in Aps.Versioning.Http 7.0.0-preview.1 + builder.WithOpenApi(operation => + { + operation.Responses[statusCode.ToString(CultureInfo.InvariantCulture)].Description = description; + return operation; + }); + + builder.Produces( + statusCode, + responseType, + contentType: contentType, + additionalContentTypes: additionalContentTypes + ); + + // // with suing https://github.com/domaindrivendev/Swashbuckle.AspNetCore#swashbuckleaspnetcoreannotations + // builder.WithMetadata(new SwaggerResponseAttribute(statusCode, description, responseType)); + return builder; + } + + public static RouteHandlerBuilder Produces( + this RouteHandlerBuilder builder, + string description, + int statusCode, + string? contentType = null, + params string[] additionalContentTypes + ) + { + builder.WithOpenApi(operation => + { + operation.Responses[statusCode.ToString(CultureInfo.InvariantCulture)].Description = description; + return operation; + }); + + builder.Produces( + statusCode, + contentType: contentType, + additionalContentTypes: additionalContentTypes + ); + + // // with suing https://github.com/domaindrivendev/Swashbuckle.AspNetCore#swashbuckleaspnetcoreannotations + // builder.WithMetadata(new SwaggerResponseAttribute( + // statusCode, + // description, + // typeof(TResponse))); + return builder; + } + + public static RouteHandlerBuilder ProducesProblem( + this RouteHandlerBuilder builder, + string description, + int statusCode, + string? contentType = null + ) + { + builder.WithOpenApi(operation => + { + operation.Responses[statusCode.ToString(CultureInfo.InvariantCulture)].Description = description; + return operation; + }); + + builder.ProducesProblem(statusCode, contentType: contentType); + + // // with suing https://github.com/domaindrivendev/Swashbuckle.AspNetCore#swashbuckleaspnetcoreannotations + // builder.WithMetadata( + // new SwaggerResponseAttribute( + // statusCode, + // description, + // typeof(ProblemDetails))); + return builder; + } + + public static RouteHandlerBuilder ProducesValidationProblem( + this RouteHandlerBuilder builder, + string description, + int statusCode = StatusCodes.Status400BadRequest, + string? contentType = null + ) + { + builder.WithOpenApi(operation => + { + operation.Responses[statusCode.ToString(CultureInfo.InvariantCulture)].Description = description; + return operation; + }); + builder.ProducesValidationProblem(statusCode, contentType: contentType); + + return builder; + } + + public static RouteHandlerBuilder WithSummaryAndDescription( + this RouteHandlerBuilder builder, + string summary, + string description + ) + { + builder.WithOpenApi(operation => + { + operation.Summary = summary; + operation.Description = description; + return operation; + }); + + //// with suing https://github.com/domaindrivendev/Swashbuckle.AspNetCore#swashbuckleaspnetcoreannotations + // builder.WithMetadata(new SwaggerOperationAttribute("Summary...", "Description...")) + return builder; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointRouteBuilderExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..6fee9475 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,136 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; + +namespace BuildingBlocks.Web.Minimal.Extensions; + +public static class EndpointRouteBuilderExtensions +{ + public static RouteHandlerBuilder MapCommandEndpoint( + this IEndpointRouteBuilder builder, + string pattern, + Func? mapRequestToCommand = null + ) + where TRequest : class + where TCommand : ICommand + { + return builder + .MapPost(pattern, Handle) + .WithName(typeof(TCommand).Name) + .WithDisplayName(typeof(TCommand).Name.Humanize()) + .WithSummaryAndDescription(typeof(TCommand).Name.Humanize(), typeof(TCommand).Name.Humanize()); + + // we can't generalize all possible type results for auto generating open-api metadata, because it might show unwanted response type as metadata + async Task Handle([AsParameters] HttpCommand requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = mapRequestToCommand is not null + ? mapRequestToCommand(request) + : mapper.Map(request); + await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.NoContent(); + } + } + + public static RouteHandlerBuilder MapCommandEndpoint( + this IEndpointRouteBuilder builder, + string pattern, + int statusCode, + Func? mapRequestToCommand = null, + Func? mapCommandResultToResponse = null, + Func? getId = null + ) + where TRequest : class + where TResponse : class + where TCommandResult : class + where TCommand : ICommand + { + return builder + .MapPost(pattern, Handle) + .WithName(typeof(TCommand).Name) + .WithDisplayName(typeof(TCommand).Name.Humanize()) + .WithSummaryAndDescription(typeof(TCommand).Name.Humanize(), typeof(TCommand).Name.Humanize()); + + // https://github.com/dotnet/aspnetcore/issues/47630 + // we can't generalize all possible type results for auto generating open-api metadata, because it might show unwanted response type as metadata + async Task Handle([AsParameters] HttpCommand requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + var host = $"{context.Request.Scheme}://{context.Request.Host}"; + + var command = mapRequestToCommand is not null + ? mapRequestToCommand(request) + : mapper.Map(request); + var res = await commandProcessor.SendAsync(command, cancellationToken); + + var response = mapCommandResultToResponse is not null + ? mapCommandResultToResponse(res) + : mapper.Map(res); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return statusCode switch + { + StatusCodes.Status201Created + => getId is { } + ? TypedResults.Created($"{host}{pattern}/{getId(response)}", response) + : TypedResults.Ok(response), + StatusCodes.Status401Unauthorized => TypedResultsExtensions.UnAuthorizedProblem(), + StatusCodes.Status500InternalServerError => TypedResultsExtensions.InternalProblem(), + StatusCodes.Status202Accepted => TypedResults.Accepted(new Uri($"{host}{pattern}"), response), + _ => TypedResults.Ok(response) + }; + } + } + + public static RouteHandlerBuilder MapQueryEndpoint( + this IEndpointRouteBuilder builder, + string pattern, + Func? mapRequestToQuery = null, + Func? mapQueryResultToResponse = null + ) + where TRequestParameters : IHttpQuery + where TResponse : class + where TQueryResult : class + where TQuery : IQuery + { + return builder + .MapGet(pattern, Handle) + .WithName(typeof(TQuery).Name) + .WithDisplayName(typeof(TQuery).Name.Humanize()) + .WithSummaryAndDescription(typeof(TQuery).Name.Humanize(), typeof(TQuery).Name.Humanize()); + + // we can't generalize all possible type results for auto generating open-api metadata, because it might show unwanted response type as metadata + async Task> Handle([AsParameters] TRequestParameters requestParameters) + { + var queryProcessor = requestParameters.QueryProcessor; + var mapper = requestParameters.Mapper; + var cancellationToken = requestParameters.CancellationToken; + + var query = mapRequestToQuery is not null + ? mapRequestToQuery(requestParameters) + : mapper.Map(requestParameters); + + var res = await queryProcessor.SendAsync(query, cancellationToken); + + var response = mapQueryResultToResponse is not null + ? mapQueryResultToResponse(res) + : mapper.Map(res); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(response); + } + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/MinimalApiExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/MinimalApiExtensions.cs new file mode 100644 index 00000000..33cc4f04 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/MinimalApiExtensions.cs @@ -0,0 +1,124 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Reflection; +using LinqKit; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Scrutor; + +namespace BuildingBlocks.Web.Minimal.Extensions; + +public static class MinimalApiExtensions +{ + public static IServiceCollection AddMinimalEndpoints( + this WebApplicationBuilder applicationBuilder, + params Assembly[] scanAssemblies + ) + { + // Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable (it is possible to get ReflectionTypeLoadException, because some dependent type assembly are lazy and not loaded yet), so we use `GetAllReferencedAssemblies` and it load all referenced assemblies explicitly. + // we also load assmblies that have some endpoints and known as a application part, because assemblies are lazy and maybe at the time of scanning, assmblies contain endpoints not visited yet. + var assemblies = scanAssemblies.Any() + ? scanAssemblies + : ReflectionUtilities + .GetReferencedAssemblies(Assembly.GetCallingAssembly()) + .Concat(ReflectionUtilities.GetApplicationPartAssemblies(Assembly.GetCallingAssembly())) + .Distinct() + .ToArray(); + + applicationBuilder.Services.Scan( + scan => + scan.FromAssemblies(assemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IMinimalEndpoint))) + .UsingRegistrationStrategy(RegistrationStrategy.Append) + .As() + .WithLifetime(ServiceLifetime.Scoped) + ); + + return applicationBuilder.Services; + } + + public static IServiceCollection AddMinimalEndpoints( + this IServiceCollection services, + params Assembly[] scanAssemblies + ) + { + // Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable (it is possible to get ReflectionTypeLoadException, because some dependent type assembly are lazy and not loaded yet), so we use `GetAllReferencedAssemblies` and it load all referenced assemblies explicitly. + // we also load assmblies that have some endpoints and known as a application part, because assemblies are lazy and maybe at the time of scanning, assmblies contain endpoints not visited yet. + var assemblies = scanAssemblies.Any() + ? scanAssemblies + : ReflectionUtilities + .GetReferencedAssemblies(Assembly.GetCallingAssembly()) + .Concat(ReflectionUtilities.GetApplicationPartAssemblies(Assembly.GetCallingAssembly())) + .Distinct() + .ToArray(); + + services.Scan( + scan => + scan.FromAssemblies(assemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IMinimalEndpoint))) + .UsingRegistrationStrategy(RegistrationStrategy.Append) + .As() + .WithLifetime(ServiceLifetime.Scoped) + ); + + return services; + } + + /// + /// Map registered minimal apis. + /// + /// + /// + public static IEndpointRouteBuilder MapMinimalEndpoints(this IEndpointRouteBuilder builder) + { + var scope = builder.ServiceProvider.CreateScope(); + + var endpoints = scope.ServiceProvider.GetServices().ToList(); + + // https://github.com/dotnet/aspnet-api-versioning/commit/b789e7e980e83a7d2f82ce3b75235dee5e0724b4 + // changed from MapApiGroup to NewVersionedApi in v7.0.0 + var versionGroups = endpoints + .GroupBy(x => x.GroupName) + .ToDictionary(x => x.Key, c => builder.NewVersionedApi(c.Key).WithTags(c.Key)); // + + var versionSubGroups = endpoints + .GroupBy( + x => + new + { + x.GroupName, + x.PrefixRoute, + x.Version + } + ) + .ToDictionary( + x => x.Key, + c => versionGroups[c.Key.GroupName].MapGroup(c.Key.PrefixRoute).HasApiVersion(c.Key.Version) + ); + + var endpointVersions = endpoints + .GroupBy(x => new { x.GroupName, x.Version }) + .Select( + x => + new + { + Verion = x.Key.Version, + x.Key.GroupName, + Endpoints = x.Select(v => v) + } + ); + + foreach (var endpointVersion in endpointVersions) + { + var versionGroup = versionSubGroups.FirstOrDefault(x => x.Key.GroupName == endpointVersion.GroupName).Value; + + endpointVersion.Endpoints.ForEach(ep => + { + ep.MapEndpoint(versionGroup); + }); + } + + return builder; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/TypedResultsExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/TypedResultsExtensions.cs new file mode 100644 index 00000000..8d824e92 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/Extensions/TypedResultsExtensions.cs @@ -0,0 +1,61 @@ +using BuildingBlocks.Web.Problem.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Minimal.Extensions; + +public static class TypedResultsExtensions +{ + public static InternalHttpProblemResult InternalProblem( + string? title = null, + string? detail = null, + string? instance = null, + string? type = null, + IDictionary? extensions = null + ) + { + var problemDetails = CreateProblem(title, detail, instance, type, extensions); + + return new(problemDetails); + } + + public static UnAuthorizedHttpProblemResult UnAuthorizedProblem( + string? title = null, + string? detail = null, + string? instance = null, + string? type = null, + IDictionary? extensions = null + ) + { + var problemDetails = CreateProblem(title, detail, instance, type, extensions); + + return new(problemDetails); + } + + private static ProblemDetails CreateProblem( + string? title, + string? detail, + string? instance, + string? type, + IDictionary? extensions + ) + { + var problemDetails = new ProblemDetails + { + Detail = detail, + Instance = instance, + Type = type + }; + + problemDetails.Title = title ?? problemDetails.Title; + + if (extensions is not null) + { + foreach (var extension in extensions) + { + problemDetails.Extensions.Add(extension); + } + } + + return problemDetails; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpCommand.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpCommand.cs new file mode 100644 index 00000000..da4fd54b --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpCommand.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Web.Minimal; + +public record HttpCommand( + TRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpQuery.cs b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpQuery.cs new file mode 100644 index 00000000..5930d111 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Minimal/HttpQuery.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Web.Minimal; + +public record HttpQuery( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Modules/Extensions/ModuleExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Modules/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..dd93fd47 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Modules/Extensions/ModuleExtensions.cs @@ -0,0 +1,119 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core.Reflection; +using BuildingBlocks.Core.Reflection.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Web.Modules.Extensions; + +public static class ModuleExtensions +{ + public static WebApplicationBuilder AddModulesServices( + this WebApplicationBuilder webApplicationBuilder, + params Assembly[] scanAssemblies + ) + { + // Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable (it is possible to get ReflectionTypeLoadException, because some dependent type assembly are lazy and not loaded yet), so we use `GetAllReferencedAssemblies` and it + // load all referenced assemblies explicitly. + var assemblies = scanAssemblies.Any() + ? scanAssemblies + : ReflectionUtilities + .GetReferencedAssemblies(Assembly.GetCallingAssembly()) + .Concat(ReflectionUtilities.GetApplicationPartAssemblies(Assembly.GetCallingAssembly())) + .Distinct() + .ToArray(); + + var modulesConfiguration = assemblies + .SelectMany(x => x.GetLoadableTypes()) + .Where( + t => + t!.IsClass + && !t.IsAbstract + && !t.IsGenericType + && !t.IsInterface + && t.GetConstructor(Type.EmptyTypes) != null + && typeof(IModuleConfiguration).IsAssignableFrom(t) + ) + .ToList(); + + var sharedModulesConfiguration = assemblies + .SelectMany(x => x.GetLoadableTypes()) + .Where( + t => + t!.IsClass + && !t.IsAbstract + && !t.IsGenericType + && !t.IsInterface + && t.GetConstructor(Type.EmptyTypes) != null + && typeof(ISharedModulesConfiguration).IsAssignableFrom(t) + ) + .ToList(); + + foreach (var sharedModule in sharedModulesConfiguration) + { + AddModulesDependencyInjection(webApplicationBuilder, sharedModule); + } + + foreach (var module in modulesConfiguration) + { + AddModulesDependencyInjection(webApplicationBuilder, module); + } + + return webApplicationBuilder; + } + + private static void AddModulesDependencyInjection(WebApplicationBuilder webApplicationBuilder, Type module) + { + if (module.IsAssignableTo(typeof(IModuleConfiguration))) + { + var instantiatedType = (IModuleConfiguration)Activator.CreateInstance(module)!; + instantiatedType.AddModuleServices(webApplicationBuilder); + webApplicationBuilder.Services.TryAddSingleton(instantiatedType); + } + + if (module.IsAssignableTo(typeof(ISharedModulesConfiguration))) + { + var instantiatedType = (ISharedModulesConfiguration)Activator.CreateInstance(module)!; + instantiatedType.AddSharedModuleServices(webApplicationBuilder); + webApplicationBuilder.Services.TryAddSingleton(instantiatedType); + } + } + + public static async Task ConfigureModules(this WebApplication app) + { + var moduleConfigurations = app.Services.GetServices(); + var sharedModulesConfigurations = app.Services.GetServices(); + + foreach (var sharedModule in sharedModulesConfigurations) + { + await sharedModule.ConfigureSharedModule(app); + } + + foreach (var module in moduleConfigurations) + { + await module.ConfigureModule(app); + } + + return app; + } + + public static IEndpointRouteBuilder MapModulesEndpoints(this IEndpointRouteBuilder builder) + { + var modules = builder.ServiceProvider.GetServices(); + var sharedModules = builder.ServiceProvider.GetServices(); + + foreach (var module in sharedModules) + { + module.MapSharedModuleEndpoints(builder); + } + + foreach (var module in modules) + { + module.MapEndpoints(builder); + } + + return builder; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/DefaultProblemDetailMapper.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/DefaultProblemDetailMapper.cs new file mode 100644 index 00000000..41df55ca --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/DefaultProblemDetailMapper.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Abstractions.Web.Problem; +using BuildingBlocks.Core.Domain.Exceptions; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Validation; +using Microsoft.AspNetCore.Http; + +namespace BuildingBlocks.Web.Problem; + +internal sealed class DefaultProblemDetailMapper : IProblemDetailMapper +{ + public int GetMappedStatusCodes(Exception exception) + { + return exception switch + { + ConflictException conflictException => conflictException.StatusCode, + ConflictAppException conflictException => conflictException.StatusCode, + ConflictDomainException conflictException => conflictException.StatusCode, + ValidationException validationException => validationException.StatusCode, + ArgumentException _ => StatusCodes.Status400BadRequest, + BadRequestException badRequestException => badRequestException.StatusCode, + NotFoundException notFoundException => notFoundException.StatusCode, + NotFoundDomainException notFoundException => notFoundException.StatusCode, + NotFoundAppException notFoundException => notFoundException.StatusCode, + HttpResponseException httpResponseException => httpResponseException.StatusCode, + HttpRequestException httpRequestException => (int)httpRequestException.StatusCode, + AppException appException => appException.StatusCode, + DomainException domainException => domainException.StatusCode, + _ => 0 + }; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ConflictHttpProblemResult.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ConflictHttpProblemResult.cs new file mode 100644 index 00000000..7e1ca788 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ConflictHttpProblemResult.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem.HttpResults; + +public class ConflictHttpProblemResult + : IResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IValueHttpResult, + IEndpointMetadataProvider +{ + private readonly ProblemHttpResult _internalResult; + + internal ConflictHttpProblemResult(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); + if (problemDetails is { Status: not null and not StatusCodes.Status409Conflict }) + { + throw new ArgumentException( + $"{nameof(ConflictHttpProblemResult)} only supports a 409 Conflict response status code.", + nameof(problemDetails) + ); + } + + problemDetails.Status ??= StatusCodes.Status409Conflict; + + _internalResult = TypedResults.Problem(problemDetails); + } + + public ProblemDetails ProblemDetails => _internalResult.ProblemDetails; + + public Task ExecuteAsync(HttpContext httpContext) + { + return _internalResult.ExecuteAsync(httpContext); + } + + public int? StatusCode => _internalResult.StatusCode; + public string? ContentType => _internalResult.ContentType; + object? IValueHttpResult.Value => _internalResult.ProblemDetails; + + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + builder.Metadata.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status409Conflict)); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ForbidenHttpProblemResult.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ForbidenHttpProblemResult.cs new file mode 100644 index 00000000..3e711362 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/ForbidenHttpProblemResult.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem.HttpResults; + +public class ForbiddenHttpProblemResult + : IResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IValueHttpResult, + IEndpointMetadataProvider +{ + private readonly ProblemHttpResult _internalResult; + + internal ForbiddenHttpProblemResult(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); + if (problemDetails is { Status: not null and not StatusCodes.Status403Forbidden }) + { + throw new ArgumentException( + $"{nameof(ForbiddenHttpProblemResult)} only supports a 403 Forbidden response status code.", + nameof(problemDetails) + ); + } + + problemDetails.Status ??= StatusCodes.Status403Forbidden; + + _internalResult = TypedResults.Problem(problemDetails); + } + + public ProblemDetails ProblemDetails => _internalResult.ProblemDetails; + + public Task ExecuteAsync(HttpContext httpContext) + { + return _internalResult.ExecuteAsync(httpContext); + } + + public int? StatusCode => _internalResult.StatusCode; + public string? ContentType => _internalResult.ContentType; + object? IValueHttpResult.Value => _internalResult.ProblemDetails; + + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + builder.Metadata.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status403Forbidden)); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/InternalHttpProblemResult.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/InternalHttpProblemResult.cs new file mode 100644 index 00000000..38e472ec --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/InternalHttpProblemResult.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem.HttpResults; + +public class InternalHttpProblemResult + : IResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IValueHttpResult, + IEndpointMetadataProvider +{ + private readonly ProblemHttpResult _internalResult; + + internal InternalHttpProblemResult(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); + if (problemDetails is { Status: not null and not StatusCodes.Status500InternalServerError }) + { + throw new ArgumentException( + $"{nameof(InternalHttpProblemResult)} only supports a 500 Internal Server Error response status code.", + nameof(problemDetails) + ); + } + + problemDetails.Status ??= StatusCodes.Status500InternalServerError; + + _internalResult = TypedResults.Problem(problemDetails); + } + + public ProblemDetails ProblemDetails => _internalResult.ProblemDetails; + + public Task ExecuteAsync(HttpContext httpContext) + { + return _internalResult.ExecuteAsync(httpContext); + } + + public int? StatusCode => _internalResult.StatusCode; + public string? ContentType => _internalResult.ContentType; + object? IValueHttpResult.Value => _internalResult.ProblemDetails; + + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + builder.Metadata.Add( + new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status500InternalServerError) + ); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/NotFoundHttpProblemResult.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/NotFoundHttpProblemResult.cs new file mode 100644 index 00000000..e7b299db --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/NotFoundHttpProblemResult.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem.HttpResults; + +public class NotFoundHttpProblemResult + : IResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IValueHttpResult, + IEndpointMetadataProvider +{ + private readonly ProblemHttpResult _internalResult; + + internal NotFoundHttpProblemResult(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); + if (problemDetails is { Status: not null and not StatusCodes.Status404NotFound }) + { + throw new ArgumentException( + $"{nameof(NotFoundHttpProblemResult)} only supports a 404 NotFound response status code.", + nameof(problemDetails) + ); + } + + problemDetails.Status ??= StatusCodes.Status404NotFound; + + _internalResult = TypedResults.Problem(problemDetails); + } + + public ProblemDetails ProblemDetails => _internalResult.ProblemDetails; + + public Task ExecuteAsync(HttpContext httpContext) + { + return _internalResult.ExecuteAsync(httpContext); + } + + public int? StatusCode => _internalResult.StatusCode; + public string? ContentType => _internalResult.ContentType; + object? IValueHttpResult.Value => _internalResult.ProblemDetails; + + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + builder.Metadata.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status404NotFound)); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/UnAuthorizedHttpProblemResult.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/UnAuthorizedHttpProblemResult.cs new file mode 100644 index 00000000..72300b78 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/HttpResults/UnAuthorizedHttpProblemResult.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem.HttpResults; + +public class UnAuthorizedHttpProblemResult + : IResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IValueHttpResult, + IEndpointMetadataProvider +{ + private readonly ProblemHttpResult _internalResult; + + internal UnAuthorizedHttpProblemResult(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); + if (problemDetails is { Status: not null and not StatusCodes.Status401Unauthorized }) + { + throw new ArgumentException( + $"{nameof(UnAuthorizedHttpProblemResult)} only supports a 401 Unauthorized response status code.", + nameof(problemDetails) + ); + } + + problemDetails.Status ??= StatusCodes.Status401Unauthorized; + + _internalResult = TypedResults.Problem(problemDetails); + } + + public ProblemDetails ProblemDetails => _internalResult.ProblemDetails; + + public Task ExecuteAsync(HttpContext httpContext) + { + return _internalResult.ExecuteAsync(httpContext); + } + + public int? StatusCode => _internalResult.StatusCode; + public string? ContentType => _internalResult.ContentType; + object? IValueHttpResult.Value => _internalResult.ProblemDetails; + + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + builder.Metadata.Add( + new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status401Unauthorized) + ); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsService.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsService.cs new file mode 100644 index 00000000..138a6486 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsService.cs @@ -0,0 +1,101 @@ +using BuildingBlocks.Abstractions.Web.Problem; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace BuildingBlocks.Web.Problem; + +// https://www.strathweb.com/2022/08/problem-details-responses-everywhere-with-asp-net-core-and-net-7/ +public class ProblemDetailsService : IProblemDetailsService +{ + private readonly IEnumerable? _problemDetailMappers; + private readonly IProblemDetailsWriter[] _writers; + + public ProblemDetailsService( + IEnumerable writers, + IEnumerable? problemDetailMappers = null + ) + { + _writers = writers.ToArray(); + _problemDetailMappers = problemDetailMappers; + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + { + ArgumentNullException.ThrowIfNull((object)context, nameof(context)); + ArgumentNullException.ThrowIfNull((object)context.ProblemDetails, "context.ProblemDetails"); + ArgumentNullException.ThrowIfNull((object)context.HttpContext, "context.HttpContext"); + + // with help of `capture exception middleware` for capturing actual thrown exception, in .net 8 preview 5 it will create automatically + IExceptionHandlerFeature? exceptionFeature = context.HttpContext.Features.Get(); + + // if we throw an exception, we should create appropriate ProblemDetail based on the exception, else we just return default ProblemDetail with status 500 or a custom ProblemDetail which is returned from the endpoint + if (exceptionFeature is not null) + { + CreateProblemDetailFromException(context, exceptionFeature); + } + + if ( + context.HttpContext.Response.HasStarted + || context.HttpContext.Response.StatusCode < 400 + || _writers.Length == 0 + ) + return ValueTask.CompletedTask; + + IProblemDetailsWriter problemDetailsWriter = null!; + if (_writers.Length == 1) + { + IProblemDetailsWriter writer = _writers[0]; + return !writer.CanWrite(context) ? ValueTask.CompletedTask : writer.WriteAsync(context); + } + + foreach (var writer in _writers) + { + if (writer.CanWrite(context)) + { + problemDetailsWriter = writer; + break; + } + } + + return problemDetailsWriter?.WriteAsync(context) ?? ValueTask.CompletedTask; + } + + private void CreateProblemDetailFromException( + ProblemDetailsContext context, + IExceptionHandlerFeature exceptionFeature + ) + { + if (_problemDetailMappers is { }) + { + foreach (var problemDetailMapper in _problemDetailMappers) + { + var mappedStatusCode = problemDetailMapper.GetMappedStatusCodes(exceptionFeature.Error); + if (mappedStatusCode > 0) + { + PopulateNewProblemDetail( + context.ProblemDetails, + context.HttpContext, + mappedStatusCode, + exceptionFeature.Error + ); + context.HttpContext.Response.StatusCode = mappedStatusCode; + } + } + } + } + + private static void PopulateNewProblemDetail( + ProblemDetails existingProblemDetails, + HttpContext httpContext, + int statusCode, + Exception exception + ) + { + // We should override ToString method in the exception for showing correct title. + existingProblemDetails.Title = exception.ToString(); + existingProblemDetails.Detail = exception.Message; + existingProblemDetails.Status = statusCode; + existingProblemDetails.Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsWriter.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsWriter.cs new file mode 100644 index 00000000..6ef8331a --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/ProblemDetailsWriter.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace BuildingBlocks.Web.Problem; + +// https://www.strathweb.com/2022/08/problem-details-responses-everywhere-with-asp-net-core-and-net-7/#toc_3 +public class ProblemDetailsWriter : IProblemDetailsWriter +{ + private readonly ProblemDetailsOptions _options; + + public ProblemDetailsWriter(IOptions options) + { + _options = options.Value; + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + { + var httpContext = context.HttpContext; + TypedResults.Problem(context.ProblemDetails); + _options.CustomizeProblemDetails?.Invoke(context); + + return new ValueTask( + httpContext.Response.WriteAsJsonAsync( + context.ProblemDetails, + options: null, + contentType: "application/problem+json" + ) + ); + } + + public bool CanWrite(ProblemDetailsContext context) + { + return true; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Web/Problem/RegistrationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Web/Problem/RegistrationExtensions.cs new file mode 100644 index 00000000..686d1806 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Web/Problem/RegistrationExtensions.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Web.Problem; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Reflection; +using Microsoft.AspNetCore.Http; +using Scrutor; + +namespace BuildingBlocks.Web.Problem; + +// https://www.strathweb.com/2022/08/problem-details-responses-everywhere-with-asp-net-core-and-net-7/ +public static class RegistrationExtensions +{ + public static IServiceCollection AddCustomProblemDetails( + this IServiceCollection services, + Action? configure = null, + params Assembly[] scanAssemblies + ) + { + var assemblies = scanAssemblies.Any() + ? scanAssemblies + : ReflectionUtilities.GetReferencedAssemblies(Assembly.GetCallingAssembly()).Distinct().ToArray(); + + services.AddProblemDetails(configure); + services.ReplaceSingleton(); + // services.TryAddSingleton(); + + RegisterAllMappers(services, assemblies); + + return services; + } + + private static void RegisterAllMappers(IServiceCollection services, Assembly[] scanAssemblies) + { + services.Scan( + scan => + scan.FromAssemblies(scanAssemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IProblemDetailMapper)), false) + .UsingRegistrationStrategy(RegistrationStrategy.Append) + .As() + .WithLifetime(ServiceLifetime.Singleton) + ); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c4e8df58..ae5ff87d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,7 +4,7 @@ latest - net7.0 + net8.0 enable enable @@ -50,6 +50,7 @@ + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 4440c61d..8e703ba9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,189 +6,205 @@ true - 1.2.4 - 1.2.4 - 1.2.4 + 1.2.4-dev.4 + 1.2.4-dev.4 + 1.2.4-dev.4 false false - - + + + + + + + + + - + - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - - + + - - + + - - - - - - - + + + + + + + - + - + - - - - - - - - - - - - + + + + + + + + + + + + - + - - - - - + + + + + - - + + + - - + + - - - + + + - + - + - + - - - - - - + + + + + - - + + - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + + - - - - + + + + - - - - - + + + + + - \ No newline at end of file + + + + diff --git a/src/Services/Billing/FoodDelivery.Services.Billing.Api/FoodDelivery.Services.Billing.Api.csproj b/src/Services/Billing/FoodDelivery.Services.Billing.Api/FoodDelivery.Services.Billing.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing.Api/FoodDelivery.Services.Billing.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Billing/FoodDelivery.Services.Billing.Api/Program.cs b/src/Services/Billing/FoodDelivery.Services.Billing.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Billing/FoodDelivery.Services.Billing.Api/Properties/launchSettings.json b/src/Services/Billing/FoodDelivery.Services.Billing.Api/Properties/launchSettings.json new file mode 100644 index 00000000..0b336f33 --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:49966", + "sslPort": 44363 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7235;http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.Development.json b/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.json b/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Billing/FoodDelivery.Services.Billing/Class1.cs b/src/Services/Billing/FoodDelivery.Services.Billing/Class1.cs new file mode 100644 index 00000000..2433f1dd --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Billing; + +public class Class1 { } diff --git a/src/Services/Billing/FoodDelivery.Services.Billing/FoodDelivery.Services.Billing.csproj b/src/Services/Billing/FoodDelivery.Services.Billing/FoodDelivery.Services.Billing.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Billing/FoodDelivery.Services.Billing/FoodDelivery.Services.Billing.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Carts/FoodDelivery.Services.Carts.Api/FoodDelivery.Services.Carts.Api.csproj b/src/Services/Carts/FoodDelivery.Services.Carts.Api/FoodDelivery.Services.Carts.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts.Api/FoodDelivery.Services.Carts.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Carts/FoodDelivery.Services.Carts.Api/Program.cs b/src/Services/Carts/FoodDelivery.Services.Carts.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Carts/FoodDelivery.Services.Carts.Api/Properties/launchSettings.json b/src/Services/Carts/FoodDelivery.Services.Carts.Api/Properties/launchSettings.json new file mode 100644 index 00000000..66a69c5a --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14469", + "sslPort": 44376 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5289", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7289;http://localhost:5289", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.Development.json b/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.json b/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Carts/FoodDelivery.Services.Carts/Class1.cs b/src/Services/Carts/FoodDelivery.Services.Carts/Class1.cs new file mode 100644 index 00000000..7df9911c --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Carts; + +public class Class1 { } diff --git a/src/Services/Carts/FoodDelivery.Services.Carts/FoodDelivery.Services.Carts.csproj b/src/Services/Carts/FoodDelivery.Services.Carts/FoodDelivery.Services.Carts.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Carts/FoodDelivery.Services.Carts/FoodDelivery.Services.Carts.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Carts/readme.md b/src/Services/Carts/readme.md new file mode 100644 index 00000000..d1797f1e --- /dev/null +++ b/src/Services/Carts/readme.md @@ -0,0 +1,2 @@ +# Carts Microservice +This microservice is used to keep customer items in the basket and ready for creating an order \ No newline at end of file diff --git a/src/Services/Catalogs/Directory.Build.props b/src/Services/Catalogs/Directory.Build.props new file mode 100644 index 00000000..b76f549e --- /dev/null +++ b/src/Services/Catalogs/Directory.Build.props @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Catalogs/Dockerfile b/src/Services/Catalogs/Dockerfile new file mode 100644 index 00000000..3d6ee35e --- /dev/null +++ b/src/Services/Catalogs/Dockerfile @@ -0,0 +1,99 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Catalogs/Directory.Build.props ./Services/Catalogs/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ + +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj ./Services/Catalogs/FoodDelivery.Services.Catalogs/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Catalogs.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN dotnet restore ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs/ ./Services/Catalogs/FoodDelivery.Services.Catalogs/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ + +RUN dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +# https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Catalogs.Api.dll"] diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/CatalogsApiMetadata.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/CatalogsApiMetadata.cs new file mode 100644 index 00000000..c48a03bd --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/CatalogsApiMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Catalogs.Api; + +public class CatalogsApiMetadata { } diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj new file mode 100644 index 00000000..164bbfce --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj @@ -0,0 +1,33 @@ + + + + true + + + + + + + + catalogs + dev + mcr.microsoft.com/dotnet/aspnet:latest + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Program.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Program.cs new file mode 100644 index 00000000..1be1f043 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Program.cs @@ -0,0 +1,86 @@ +using Bogus; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Web; +using BuildingBlocks.Swagger; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Modules.Extensions; +using FoodDelivery.Services.Catalogs; +using Spectre.Console; + +AnsiConsole.Write(new FigletText("Catalogs Service").Centered().Color(Color.FromInt32(new Faker().Random.Int(1, 255)))); + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis +// https://benfoster.io/blog/mvc-to-minimal-apis-aspnet-6/ +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider( + (context, options) => + { + var isDevMode = + context.HostingEnvironment.IsDevelopment() + || context.HostingEnvironment.IsTest() + || context.HostingEnvironment.IsStaging(); + + // Handling Captive Dependency Problem + // https://ankitvijay.net/2020/03/17/net-core-and-di-beware-of-captive-dependency/ + // https://levelup.gitconnected.com/top-misconceptions-about-dependency-injection-in-asp-net-core-c6a7afd14eb4 + // https://blog.ploeh.dk/2014/06/02/captive-dependency/ + // https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/web-host?view=aspnetcore-7.0&viewFallbackFrom=aspnetcore-2.2#scope-validation + // CreateDefaultBuilder and WebApplicationBuilder in minimal apis sets `ServiceProviderOptions.ValidateScopes` and `ServiceProviderOptions.ValidateOnBuild` to true if the app's environment is Development. + // check dependencies are used in a valid life time scope + options.ValidateScopes = isDevMode; + // validate dependencies on the startup immediately instead of waiting for using the service - Issue with masstransit #85 + // options.ValidateOnBuild = isDevMode; + } +); + +// https://www.talkingdotnet.com/disable-automatic-model-state-validation-in-asp-net-core-2-1/ +builder.Services.Configure(options => +{ + options.SuppressModelStateInvalidFilter = true; +}); + +builder.Services.AddValidatedOptions(); + +// register endpoints +builder.AddMinimalEndpoints(typeof(CatalogsMetadata).Assembly); + +/*----------------- Module Services Setup ------------------*/ +builder.AddModulesServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsTest()) +{ + app.Services.ValidateDependencies( + builder.Services, + typeof(CatalogsMetadata).Assembly, + Assembly.GetExecutingAssembly() + ); +} + +/*----------------- Module Middleware Setup ------------------*/ +await app.ConfigureModules(); + +// https://thecodeblogger.com/2021/05/27/asp-net-core-web-application-routing-and-endpoint-internals/ +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#routing-basics +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#endpoints +// https://stackoverflow.com/questions/57846127/what-are-the-differences-between-app-userouting-and-app-useendpoints +// in .net 6 and above we don't need UseRouting and UseEndpoints but if ordering is important we should write it +// app.UseRouting(); + +/*----------------- Module Routes Setup ------------------*/ +app.MapModulesEndpoints(); + +// automatic discover minimal endpoints +app.MapMinimalEndpoints(); + +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("docker")) +{ + // should register as last middleware for discovering all endpoints and its versions correctly + app.UseCustomSwagger(); +} + +await app.RunAsync(); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Properties/launchSettings.json b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Properties/launchSettings.json new file mode 100644 index 00000000..71c2a8d9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/Properties/launchSettings.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json.schemaecommerce.org/launchsettings.json", + "profiles": { + "Catalogs.Api.Http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:4000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Catalogs.Api.Https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "https://localhost:4001;http://localhost:4000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Catalogs.Api.Watch": { + "commandName": "Executable", + "executablePath": "dotnet", + "hotReloadEnabled": true, + "hotReloadProfile" : "aspnetcore", + "workingDirectory": "$(ProjectDir)", + "commandLineArgs": "watch -lp Catalogs.Api.Http", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Catalogs.Api.LiveRecompilation": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:4000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.development.json b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.development.json new file mode 100644 index 00000000..f9580005 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.development.json @@ -0,0 +1,15 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json new file mode 100644 index 00000000..dbb2c25a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.docker.json @@ -0,0 +1,41 @@ +{ + "AppOptions": { + "Name": "Catalogs Api", + "Description": "Catalogs Api", + "ApiAddress": "http://localhost:5000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@mongo:27017", + "DatabaseName": "food-delivery-services-catalogs" + }, + "PostgresOptions": { + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_catalogs;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "RabbitMqOptions": { + "Host": "rabbitmq", + "UserName": "guest", + "Password": "guest" + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_catalogs;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.json b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.json new file mode 100644 index 00000000..4a45775a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.json @@ -0,0 +1,76 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning", + "MassTransit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "AppOptions": { + "Name": "Catalogs Api", + "Description": "Catalogs Api", + "ApiAddress": "http://localhost:5000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@localhost:27017", + "DatabaseName": "food-delivery-services-catalogs" + }, + "PostgresOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_catalogs;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "EmailOptions": { + "From": "info@my-food-delivery-service.com", + "DisplayName": "Food Delivery Application Mail", + "Enable": true, + "MimeKitOptions": { + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "", + "Password": "" + } + }, + "PolicyOptions": { + "RetryCount": 3, + "BreakDuration": 30, + "TimeOutDuration": 15 + }, + "RabbitMqOptions": { + "Host": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest" + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_catalogs;User Id=postgres;Password=postgres;Include Error Detail=true" + }, + "HealthOptions": { + "Enabled": false + }, + "ConfigurationFolder": "config-files/" +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.test.json b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.test.json new file mode 100644 index 00000000..4b0b3516 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/appsettings.test.json @@ -0,0 +1,11 @@ +{ + "MongoContainerOptions": { + "DatabaseName": "food-delivery-services-catalogs_test", + "ImageName": "mongo:latest" + }, + "PostgresContainerOptions": { + "DatabaseName": "food_delivery_services_catalogs_test", + "ImageName": "postgres", + "MigrationAssembly": "FoodDelivery.Services.Catalogs" + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Brand.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Brand.cs new file mode 100644 index 00000000..8627002d --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Brand.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Brands; + +public class Brand : Aggregate +{ + public BrandName Name { get; private set; } = default!; + + public static Brand Of(BrandId id, BrandName name) + { + // input validation will do in the `command` and our `value objects` before arriving to entity and makes or domain cleaner (but we have to check against for our value objects), here we just do business validation + id.NotBeNull(); + name.NotBeNull(); + + var brand = new Brand { Id = id, }; + + brand.ChangeName(name); + + return brand; + } + + public void ChangeName(BrandName name) + { + Name = name; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Configs.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Configs.cs new file mode 100644 index 00000000..01ff5a29 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Configs.cs @@ -0,0 +1,29 @@ +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.Data; +using FoodDelivery.Services.Catalogs.Brands.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.Brands; + +internal class Configs : IModuleConfiguration +{ + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + return endpoints; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Contracts/IBrandChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Contracts/IBrandChecker.cs new file mode 100644 index 00000000..4791230a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Contracts/IBrandChecker.cs @@ -0,0 +1,8 @@ +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Brands.Contracts; + +public interface IBrandChecker +{ + bool BrandExists(BrandId? brandId); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandDataSeeder.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandDataSeeder.cs new file mode 100644 index 00000000..0a890949 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandDataSeeder.cs @@ -0,0 +1,37 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Brands.Data; + +public class BrandDataSeeder : IDataSeeder +{ + private readonly ICatalogDbContext _context; + + public BrandDataSeeder(ICatalogDbContext context) + { + _context = context; + } + + public async Task SeedAllAsync() + { + if (await _context.Brands.AnyAsync()) + return; + + // https://www.youtube.com/watch?v=T9pwE1GAr_U + // https://jackhiston.com/2017/10/1/how-to-create-bogus-data-in-c/ + // https://khalidabuhakmeh.com/seed-entity-framework-core-with-bogus + // https://github.com/bchavez/Bogus#bogus-api-support + // https://github.com/bchavez/Bogus/blob/master/Examples/EFCoreSeedDb/Program.cs#L74 + + // faker works with normal syntax because brand has a default constructor + var brands = new BrandFaker().Generate(5); + + await _context.Brands.AddRangeAsync(brands); + await _context.SaveChangesAsync(); + } + + public int Order => 3; +} + +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with `CustomInstantiator` and we should exclude theme one by one diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandEntityConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandEntityConfiguration.cs new file mode 100644 index 00000000..e2987296 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandEntityConfiguration.cs @@ -0,0 +1,29 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Brands.Data; + +public class BrandEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Brand).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.OwnsOne( + x => x.Name, + a => + { + // configuration just for changing column name in db (instead of name_value) + a.Property(p => p.Value).HasColumnName(nameof(Brand.Name).Underscore()).IsRequired(); + } + ); + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandFaker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandFaker.cs new file mode 100644 index 00000000..ff23d08e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Data/BrandFaker.cs @@ -0,0 +1,19 @@ +using Bogus; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Brands.Data; + +public sealed class BrandFaker : Faker +{ + public BrandFaker() + { + // https://www.youtube.com/watch?v=T9pwE1GAr_U + // https://jackhiston.com/2017/10/1/how-to-create-bogus-data-in-c/ + // https://khalidabuhakmeh.com/seed-entity-framework-core-with-bogus + // https://github.com/bchavez/Bogus#bogus-api-support + // https://github.com/bchavez/Bogus/blob/master/Examples/EFCoreSeedDb/Program.cs#L74 + long id = 1; + + CustomInstantiator(f => Brand.Of(BrandId.Of(id++), BrandName.Of(f.Company.CompanyName()))); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Application/BrandNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Application/BrandNotFoundException.cs new file mode 100644 index 00000000..bd4257d2 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Application/BrandNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Brands.Exceptions.Application; + +public class BrandNotFoundException : NotFoundAppException +{ + public BrandNotFoundException(long id) + : base($"Brand with id '{id}' not found") { } + + public BrandNotFoundException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Domain/BrandNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Domain/BrandNotFoundException.cs new file mode 100644 index 00000000..57d8d2bf --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Exceptions/Domain/BrandNotFoundException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Brands.Exceptions.Domain; + +public class BrandNotFoundException : NotFoundDomainException +{ + public BrandNotFoundException(Type businessRuleType, long id) + : base(businessRuleType, $"Brand with id '{id}' not found") { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Services/BrandChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Services/BrandChecker.cs new file mode 100644 index 00000000..3a21e9af --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/Services/BrandChecker.cs @@ -0,0 +1,25 @@ +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; + +namespace FoodDelivery.Services.Catalogs.Brands.Services; + +public class BrandChecker : IBrandChecker +{ + private readonly ICatalogDbContext _catalogDbContext; + + public BrandChecker(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public bool BrandExists(BrandId? brandId) + { + brandId.NotBeNull(); + var brand = _catalogDbContext.FindBrand(brandId); + + return brand is not null; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandId.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandId.cs new file mode 100644 index 00000000..ce538810 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandId.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +public record BrandId : AggregateId +{ + // EF + private BrandId(long value) + : base(value) { } + + public static implicit operator long(BrandId id) => id.Value; + + // validations should be placed here instead of constructor + public static BrandId Of(long id) => new(id.NotBeNegativeOrZero()); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandName.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandName.cs new file mode 100644 index 00000000..ad83bf44 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Brands/ValueObjects/BrandName.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record BrandName +{ + // EF + private BrandName() { } + + public string Value { get; private set; } = default!; + + public static BrandName Of([NotNull] string? value) + { + // validations should be placed here instead of constructor + value.NotBeNullOrWhiteSpace(); + + return new BrandName { Value = value }; + } + + public static implicit operator string(BrandName value) => value.Value; + + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogCacheKey.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogCacheKey.cs new file mode 100644 index 00000000..20d40003 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogCacheKey.cs @@ -0,0 +1,8 @@ +namespace FoodDelivery.Services.Catalogs; + +public static class CatalogCacheKey +{ + public static string ProductsByCategory(long categoryId) => $"{nameof(ProductsByCategory)}{categoryId}"; + + public static string ProductsWithDiscounts(long id) => $"{nameof(ProductsWithDiscounts)}{id}"; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogConstants.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogConstants.cs new file mode 100644 index 00000000..a4e78d72 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogConstants.cs @@ -0,0 +1,12 @@ +namespace FoodDelivery.Services.Catalogs; + +public static class CatalogConstants +{ + public static string IdentityRoleName => "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"; + + public static class Role + { + public const string Admin = "admin"; + public const string User = "user"; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogsMetadata.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogsMetadata.cs new file mode 100644 index 00000000..46968a19 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/CatalogsMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Catalogs; + +public class CatalogsMetadata { } diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Category.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Category.cs new file mode 100644 index 00000000..abe3c717 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Category.cs @@ -0,0 +1,72 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.Exceptions; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Categories.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Categories; + +// https://stackoverflow.com/a/32354885/581476 +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://github.com/dotnet/efcore/issues/29940 +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public class Category : Aggregate +{ + // EF + // this constructor is needed when we have a parameter constructor that has some navigation property classes in the parameters and ef will skip it and try to find other constructor, here default constructor (maybe will fix .net 8) + private Category() { } + + public CategoryName Name { get; private set; } + public CategoryImage Image { get; private set; } + public CategoryCode Code { get; private set; } + public string? Description { get; private set; } + + public static Category Create( + CategoryId? id, + CategoryName? name, + CategoryCode? code, + CategoryImage? image, + string? description = "" + ) + { + // Alexey Zimarev book: input validation will do in the `command` and our `value objects` before arriving to entity and makes or domain cleaner (but we have to check against for our value objects), here we just do business validation + var category = new Category { Id = id.NotBeNull() }; + + category.ChangeName(name); + category.ChangeDescription(description); + category.ChangeCode(code); + category.ChangeImage(image); + + return category; + } + + public void ChangeName(CategoryName? name) + { + // input validation will do in the command and our value objects, here we just do business validation + name.NotBeNull(new DomainException("CategoryName can't be null.")); + Name = name; + } + + public void ChangeCode(CategoryCode? code) + { + code.NotBeNull(new DomainException("CategoryCode can't be null.")); + Code = code; + } + + public void ChangeImage(CategoryImage? image) + { + image.NotBeNull(new DomainException("Image can't be null.")); + + Image = image; + } + + public void ChangeDescription(string? description) + { + Description = description; + } + + public override string ToString() + { + return $"{Name} - {Code}"; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryId.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryId.cs new file mode 100644 index 00000000..5a323f84 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryId.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Categories; + +public record CategoryId : AggregateId +{ + // EF + private CategoryId(long value) + : base(value) { } + + public static implicit operator long(CategoryId id) => id.Value; + + // validations should be placed here instead of constructor + public static CategoryId Of(long id) => new(id.NotBeNegativeOrZero()); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryImage.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryImage.cs new file mode 100644 index 00000000..bb5fb731 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/CategoryImage.cs @@ -0,0 +1,22 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Domain; + +namespace FoodDelivery.Services.Catalogs.Categories; + +public class CategoryImage : Entity +{ + // Id will use in the url + public CategoryImage(EntityId id, string imageUrl, bool isMain, CategoryId categoryId) + { + Id = id; + ImageUrl = imageUrl; + CategoryId = categoryId; + } + + // Just for EF + private CategoryImage() { } + + public string ImageUrl { get; private set; } = default!; + public Category Category { get; private set; } = default!; + public CategoryId CategoryId { get; private set; } = default!; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Configs.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Configs.cs new file mode 100644 index 00000000..24e01613 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Configs.cs @@ -0,0 +1,29 @@ +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Catalogs.Categories.Contracts; +using FoodDelivery.Services.Catalogs.Categories.Data; +using FoodDelivery.Services.Catalogs.Categories.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.Categories; + +internal class Configs : IModuleConfiguration +{ + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + return endpoints; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Contracts/ICategoryChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Contracts/ICategoryChecker.cs new file mode 100644 index 00000000..c36f24a6 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Contracts/ICategoryChecker.cs @@ -0,0 +1,6 @@ +namespace FoodDelivery.Services.Catalogs.Categories.Contracts; + +public interface ICategoryChecker +{ + bool CategoryExists(CategoryId categoryId); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryDataSeeder.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryDataSeeder.cs new file mode 100644 index 00000000..4909465c --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryDataSeeder.cs @@ -0,0 +1,35 @@ +using AutoBogus; +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Categories.Data; + +public class CategoryDataSeeder : IDataSeeder +{ + private readonly ICatalogDbContext _dbContext; + + public CategoryDataSeeder(ICatalogDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task SeedAllAsync() + { + if (await _dbContext.Categories.AnyAsync()) + return; + + // https://jackhiston.com/2017/10/1/how-to-create-bogus-data-in-c/ + // https://khalidabuhakmeh.com/seed-entity-framework-core-with-bogus + // https://github.com/bchavez/Bogus#bogus-api-support + // https://github.com/bchavez/Bogus/blob/master/Examples/EFCoreSeedDb/Program.cs#L74 + + // faker works with normal syntax because category has a default constructor + var categories = new CategoryFaker().Generate(5); + + await _dbContext.Categories.AddRangeAsync(categories); + await _dbContext.SaveChangesAsync(); + } + + public int Order => 1; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryEntityTypeConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryEntityTypeConfiguration.cs new file mode 100644 index 00000000..b43b8622 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Categories.Data; + +public class CategoryEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Category).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.OwnsOne( + ci => ci.Name, + a => + { + // configuration just for changing column name in db (instead of name_value) + a.Property(p => p.Value).HasColumnName(nameof(Category.Name).Underscore()).IsRequired(); + } + ); + + builder.OwnsOne( + ci => ci.Code, + a => + { + // configuration just for changing column name in db (instead of code_value) + a.Property(p => p.Value).HasColumnName(nameof(Category.Code).Underscore()).IsRequired(); + } + ); + + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryFaker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryFaker.cs new file mode 100644 index 00000000..29e85b4b --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Data/CategoryFaker.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using Bogus; +using BuildingBlocks.Abstractions.Domain; +using FoodDelivery.Services.Catalogs.Categories.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Categories.Data; + +public sealed class CategoryFaker : Faker +{ + public CategoryFaker() + { + var categoryId = 1; + var imageId = 1; + + CustomInstantiator(f => + { + var generatedCid = CategoryId.Of(categoryId++); + return Category.Create( + generatedCid, + CategoryName.Of(f.Commerce.Categories(1).First()), + CategoryCode.Of(f.Random.Number(1000, 5000).ToString()), + new CategoryImage(EntityId.Of(imageId), f.Internet.Url(), f.Random.Bool(), generatedCid), + f.Commerce.ProductDescription()); + }); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Application/CategoryNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Application/CategoryNotFoundException.cs new file mode 100644 index 00000000..ae2a1d6e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Application/CategoryNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Categories.Exceptions.Application; + +public class CategoryNotFoundException : NotFoundAppException +{ + public CategoryNotFoundException(long id) + : base($"Category with id '{id}' not found.") { } + + public CategoryNotFoundException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Domain/CategoryNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Domain/CategoryNotFoundException.cs new file mode 100644 index 00000000..f63d7d4f --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Exceptions/Domain/CategoryNotFoundException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Categories.Exceptions.Domain; + +public class CategoryNotFoundException : NotFoundDomainException +{ + public CategoryNotFoundException(Type businessRuleType, long id) + : base(businessRuleType, $"Category with id '{id}' not found.") { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Services/CategoryChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Services/CategoryChecker.cs new file mode 100644 index 00000000..576963a0 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/Services/CategoryChecker.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Categories.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; + +namespace FoodDelivery.Services.Catalogs.Categories.Services; + +public class CategoryChecker : ICategoryChecker +{ + private readonly ICatalogDbContext _catalogDbContext; + + public CategoryChecker(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public bool CategoryExists(CategoryId categoryId) + { + categoryId.NotBeNull(); + var category = _catalogDbContext.FindCategory(categoryId); + + return category is not null; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryCode.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryCode.cs new file mode 100644 index 00000000..e81dc588 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryCode.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Categories.ValueObjects; + +// https://enterprisecraftsmanship.com/posts/functional-c-primitive-obsession/ +// https://enterprisecraftsmanship.com/posts/functional-c-non-nullable-reference-types/ +// https://enterprisecraftsmanship.com/posts/functional-c-primitive-obsession/ +// https://enterprisecraftsmanship.com/posts/functional-c-non-nullable-reference-types/ +public record CategoryCode +{ + // EF + private CategoryCode() { } + + public string Value { get; private set; } = default!; + + public static CategoryCode Of([NotNull] string? value) + { + // validations should be placed here instead of constructor + value.NotBeNullOrWhiteSpace(); + + return new CategoryCode { Value = value }; + } + + public static implicit operator string(CategoryCode value) => value.Value; + + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryName.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryName.cs new file mode 100644 index 00000000..e8082b46 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Categories/ValueObjects/CategoryName.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Categories.ValueObjects; + +// https://enterprisecraftsmanship.com/posts/functional-c-primitive-obsession/ +// https://enterprisecraftsmanship.com/posts/functional-c-non-nullable-reference-types/ +public record CategoryName +{ + // EF + private CategoryName() { } + + public string Value { get; private set; } = default!; + + public static CategoryName Of([NotNull] string? value) + { + // validations should be placed here instead of constructor + value.NotBeNullOrWhiteSpace(); + + return new CategoryName { Value = value }; + } + + public static implicit operator string(CategoryName value) => value.Value; + + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj new file mode 100644 index 00000000..2a5f8925 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj @@ -0,0 +1,48 @@ + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductDataSeeder.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductDataSeeder.cs new file mode 100644 index 00000000..718498ab --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductDataSeeder.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public class ProductDataSeeder : IDataSeeder +{ + private readonly ICatalogDbContext _dbContext; + + public ProductDataSeeder(ICatalogDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task SeedAllAsync() + { + if (await _dbContext.Products.AnyAsync()) + return; + + var products = new ProductFaker().Generate(5); + + await _dbContext.Products.AddRangeAsync(products); + await _dbContext.SaveChangesAsync(); + } + + public int Order => 4; +} + +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with `CustomInstantiator` and we should exclude theme one by one diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductEntityTypeConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductEntityTypeConfiguration.cs new file mode 100644 index 00000000..e5b4234a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductEntityTypeConfiguration.cs @@ -0,0 +1,105 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Data; +using FoodDelivery.Services.Catalogs.Suppliers; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public class ProductEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Product).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.OwnsOne( + ci => ci.Name, + a => + { + // configuration just for changing column name in db (instead of name_value) + a.Property(p => p.Value).HasColumnName(nameof(Product.Name).Underscore()).IsRequired(); + } + ); + + builder.OwnsOne( + ci => ci.ProductInformation, + a => + { + a.Property(p => p.Title).IsRequired(); + a.Property(p => p.Content).IsRequired(); + } + ); + + builder.OwnsOne( + ci => ci.Price, + a => + { + // configuration just for changing column name in db (instead of price_value) + a.Property(p => p.Value) + .HasColumnType(EfConstants.ColumnTypes.PriceDecimal) + .HasColumnName(nameof(Product.Price).Underscore()) + .IsRequired(); + } + ); + + builder.OwnsOne( + ci => ci.Size, + a => + { + // configuration just for changing column name in db (instead of size_value) + a.Property(p => p.Value).HasColumnName(nameof(Product.Size).Underscore()).IsRequired(); + } + ); + + builder + .Property(x => x.ProductStatus) + .HasDefaultValue(ProductStatus.Available) + .HasMaxLength(EfConstants.Lenght.Short) + .HasConversion(x => x.ToString(), x => (ProductStatus)Enum.Parse(typeof(ProductStatus), x)); + + builder + .Property(x => x.ProductType) + .HasDefaultValue(ProductType.Food) + .HasMaxLength(EfConstants.Lenght.Short) + .HasConversion(x => x.ToString(), x => (ProductType)Enum.Parse(typeof(ProductType), x)); + + builder + .Property(x => x.Color) + .HasDefaultValue(ProductColor.Black) + .HasMaxLength(EfConstants.Lenght.Short) + .HasConversion(x => x.ToString(), x => (ProductColor)Enum.Parse(typeof(ProductColor), x)); + + builder.OwnsOne(c => c.Dimensions); + + builder.OwnsOne(c => c.Stock); + + builder.Property(x => x.CategoryId); + + builder.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.CategoryId); + + builder.Property(x => x.BrandId); + + builder.HasOne(x => x.Brand).WithMany().HasForeignKey(x => x.BrandId); + + builder.Property(x => x.SupplierId); + + builder.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId); + + builder + .HasMany(s => s.Images) + .WithOne(s => s.Product) + .HasForeignKey(x => x.ProductId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductFaker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductFaker.cs new file mode 100644 index 00000000..61efd1f2 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductFaker.cs @@ -0,0 +1,55 @@ +using Bogus; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; +using NSubstitute; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public sealed class ProductFaker : Faker +{ + public ProductFaker() + { + // https://www.youtube.com/watch?v=T9pwE1GAr_U + // https://jackhiston.com/2017/10/1/how-to-create-bogus-data-in-c/ + // https://khalidabuhakmeh.com/seed-entity-framework-core-with-bogus + // https://github.com/bchavez/Bogus#bogus-api-support + // https://github.com/bchavez/Bogus/blob/master/Examples/EFCoreSeedDb/Program.cs#L74 + long id = 1; + + var supplierChecker = Substitute.For(); + supplierChecker.SupplierExists(Arg.Any()).Returns(true); + + var brandChecker = Substitute.For(); + brandChecker.BrandExists(Arg.Any()).Returns(true); + + // we should not instantiate customer aggregate manually because it is possible we break aggregate invariant in creating a product, and it is better we + // create a product with its factory method + CustomInstantiator( + faker => + Product.Create( + ProductId.Of(id++), + Name.Of(faker.Commerce.ProductName()), + ProductInformation.Of(faker.Commerce.ProductName(), faker.Commerce.ProductDescription()), + Stock.Of(faker.Random.Int(10, 20), 5, 20), + faker.PickRandom(), + faker.PickRandom(), + Dimensions.Of(faker.Random.Int(10, 50), faker.Random.Int(10, 50), faker.Random.Int(10, 50)), + Size.Of(faker.PickRandom("M", "S", "L")), + faker.Random.Enum(), + faker.Commerce.ProductDescription(), + Price.Of(faker.PickRandom(100, 200, 500)), + CategoryId.Of(faker.Random.Long(1, 3)), + SupplierId.Of(faker.Random.Long(1, 5)), + BrandId.Of(faker.Random.Long(1, 5)), + categoryId => Task.FromResult(true), + supplierChecker, + brandChecker + ) + ); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductImageEntityTypeConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductImageEntityTypeConfiguration.cs new file mode 100644 index 00000000..375fa4b4 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductImageEntityTypeConfiguration.cs @@ -0,0 +1,19 @@ +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public class ProductImageEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(ProductImage).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductViewEntityTypeConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductViewEntityTypeConfiguration.cs new file mode 100644 index 00000000..fe8b67b9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/ProductViewEntityTypeConfiguration.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public class ProductViewEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(ProductView).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + builder.HasKey(x => x.ProductId); + builder.HasIndex(x => x.ProductId).IsUnique(); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/SieveProductReadConfigurations.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/SieveProductReadConfigurations.cs new file mode 100644 index 00000000..7978ce6e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Data/SieveProductReadConfigurations.cs @@ -0,0 +1,15 @@ +using FoodDelivery.Services.Catalogs.Products.Models.Read; +using Sieve.Services; + +namespace FoodDelivery.Services.Catalogs.Products.Data; + +public class SieveProductReadConfigurations : ISieveConfiguration +{ + public void Configure(SievePropertyMapper mapper) + { + mapper.Property(p => p.Id).CanFilter().CanSort().HasName("id"); + mapper.Property(p => p.Name).CanFilter().CanSort().HasName("name"); + mapper.Property(p => p.CategoryId).CanSort().CanFilter().HasName("categoryId"); + mapper.Property(p => p.Price).CanFilter().HasName("price"); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductDto.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductDto.cs new file mode 100644 index 00000000..85205e6a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductDto.cs @@ -0,0 +1,26 @@ +using FoodDelivery.Services.Catalogs.Products.Models; + +namespace FoodDelivery.Services.Catalogs.Products.Dtos.v1; + +public record ProductDto( + long Id, + string Name, + decimal Price, + long CategoryId, + string CategoryName, + long SupplierId, + string SupplierName, + long BrandId, + string BrandName, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + ProductStatus ProductStatus, + ProductColor ProductColor, + string Size, + int Height, + int Width, + int Depth, + string? Description, + IEnumerable? Images +); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductImageDto.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductImageDto.cs new file mode 100644 index 00000000..ad4f71bc --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductImageDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Catalogs.Products.Dtos.v1; + +public record ProductImageDto(long Id, long ProductId, string ImageUrl, bool IsMain = false); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductViewDto.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductViewDto.cs new file mode 100644 index 00000000..2c932dc5 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Dtos/v1/ProductViewDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Catalogs.Products.Dtos.v1; + +public record ProductViewDto(long Id, string Name, string CategoryName, string SupplierName, long ItemCount); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Application/ProductNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Application/ProductNotFoundException.cs new file mode 100644 index 00000000..5029bdaf --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Application/ProductNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Products.Exceptions.Application; + +public class ProductNotFoundException : AppException +{ + public ProductNotFoundException(long id) + : base($"Product with id '{id}' not found", StatusCodes.Status404NotFound) { } + + public ProductNotFoundException(string message) + : base(message, StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/InsufficientStockException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/InsufficientStockException.cs new file mode 100644 index 00000000..551910c9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/InsufficientStockException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; + +public class InsufficientStockException : DomainException +{ + public InsufficientStockException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/MaxStockThresholdReachedException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/MaxStockThresholdReachedException.cs new file mode 100644 index 00000000..14ec5e6c --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/MaxStockThresholdReachedException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; + +public class MaxStockThresholdReachedException : DomainException +{ + public MaxStockThresholdReachedException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/ProductDomainException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/ProductDomainException.cs new file mode 100644 index 00000000..89ff0a13 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Exceptions/Domain/ProductDomainException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; + +public class ProductDomainException : DomainException +{ + public ProductDomainException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/ChangeMaxThreshold.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/ChangeMaxThreshold.cs new file mode 100644 index 00000000..5b42eab8 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/ChangeMaxThreshold.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingMaxThreshold.v1; + +public record ChangeMaxThreshold(long ProductId, int NewMaxThreshold) : ITxCommand; diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/MaxThresholdChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/MaxThresholdChanged.cs new file mode 100644 index 00000000..f038b1e7 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingMaxThreshold/v1/MaxThresholdChanged.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingMaxThreshold.v1; + +internal record MaxThresholdChanged(long ProductId, int MaxThreshold) : DomainEvent +{ + public static MaxThresholdChanged Of(long productId, int maxThreshold) + { + productId.NotBeNegativeOrZero(); + maxThreshold.NotBeNegativeOrZero(); + + return new MaxThresholdChanged(productId, maxThreshold); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/ChangeProductBrand.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/ChangeProductBrand.cs new file mode 100644 index 00000000..319f606d --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/ChangeProductBrand.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductBrand.v1; + +internal record ChangeProductBrand : ITxCommand; + +internal record ChangeProductBrandResult; + +internal class ChangeProductBrandHandler : ICommandHandler +{ + public Task Handle(ChangeProductBrand command, CancellationToken cancellationToken) + { + return Task.FromResult(null!); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/Events/Domain/ProductBrandChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/Events/Domain/ProductBrandChanged.cs new file mode 100644 index 00000000..be8f82b8 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductBrand/v1/Events/Domain/ProductBrandChanged.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductBrand.v1.Events.Domain; + +internal record ProductBrandChanged(long BrandId, long ProductId) : DomainEvent +{ + public static ProductBrandChanged Of(long brandId, long productId) + { + brandId.NotBeNegativeOrZero(); + productId.NotBeNegativeOrZero(); + + return new ProductBrandChanged(brandId, productId); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/ChangeProductCategory.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/ChangeProductCategory.cs new file mode 100644 index 00000000..f550bd40 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/ChangeProductCategory.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductCategory.v1; + +internal record ChangeProductCategory : ITxCommand; + +internal record ChangeProductCategoryResult; + +internal class ChangeProductCategoryHandler : ICommandHandler +{ + public Task Handle(ChangeProductCategory command, CancellationToken cancellationToken) + { + return Task.FromResult(null!); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChanged.cs new file mode 100644 index 00000000..6cfbb627 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChanged.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductCategory.v1.Events; + +internal record ProductCategoryChanged(long CategoryId, long ProductId) : DomainEvent +{ + public static ProductCategoryChanged Of(long categoryId, long productId) + { + categoryId.NotBeNegativeOrZero(); + productId.NotBeNegativeOrZero(); + + return new ProductCategoryChanged(categoryId, productId); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChangedNotification.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChangedNotification.cs new file mode 100644 index 00000000..16b3eb06 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductCategory/v1/Events/ProductCategoryChangedNotification.cs @@ -0,0 +1,7 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductCategory.v1.Events; + +public record ProductCategoryChangedNotification(CategoryId CategoryId, ProductId ProductId) : DomainEvent; diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductPrice/v1/ProductPriceChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductPrice/v1/ProductPriceChanged.cs new file mode 100644 index 00000000..d66bdd59 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductPrice/v1/ProductPriceChanged.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductPrice.v1; + +public record ProductPriceChanged(decimal Price) : DomainEvent +{ + public static ProductPriceChanged Of(decimal price) + { + price.NotBeNegativeOrZero(); + + return new ProductPriceChanged(price); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/ChangeProductSupplier.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/ChangeProductSupplier.cs new file mode 100644 index 00000000..48db5500 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/ChangeProductSupplier.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductSupplier.v1; + +internal record ChangeProductSupplier : ITxCommand; + +internal record ChangeProductSupplierResult; + +internal class ChangeProductSupplierCommandHandler : ICommandHandler +{ + public Task Handle(ChangeProductSupplier request, CancellationToken cancellationToken) + { + return Task.FromResult(null!); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/Events/ProductSupplierChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/Events/ProductSupplierChanged.cs new file mode 100644 index 00000000..679f0736 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingProductSupplier/v1/Events/ProductSupplierChanged.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Suppliers; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingProductSupplier.v1.Events; + +internal record ProductSupplierChanged(long SupplierId, long ProductId) : DomainEvent +{ + public static ProductSupplierChanged Of(long supplierId, long productId) + { + supplierId.NotBeNegativeOrZero(); + productId.NotBeNegativeOrZero(); + + return new ProductSupplierChanged(supplierId, productId); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/ChangeRestockThreshold.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/ChangeRestockThreshold.cs new file mode 100644 index 00000000..28e081f8 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/ChangeRestockThreshold.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingRestockThreshold.v1; + +public record ChangeRestockThreshold(long ProductId, int NewRestockThreshold) : ITxCommand; diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/RestockThresholdChanged.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/RestockThresholdChanged.cs new file mode 100644 index 00000000..7c55baaa --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ChangingRestockThreshold/v1/RestockThresholdChanged.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ChangingRestockThreshold.v1; + +internal record RestockThresholdChanged(long ProductId, int RestockThreshold) : DomainEvent +{ + public static RestockThresholdChanged Of(long productId, int restockThreshold) + { + productId.NotBeNegativeOrZero(); + restockThreshold.NotBeNegativeOrZero(); + + return new RestockThresholdChanged(productId, restockThreshold); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProduct.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProduct.cs new file mode 100644 index 00000000..5c18d20e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProduct.cs @@ -0,0 +1,201 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.IdsGenerator; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Categories.Contracts; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://enterprisecraftsmanship.com/posts/functional-c-primitive-obsession/ + +// prevent duplicate validation with using value-objects in command and domain model +internal record CreateProduct( + Name Name, + Price Price, + Stock Stock, + ProductStatus Status, + ProductType ProductType, + Dimensions Dimensions, + Size Size, + ProductColor Color, + CategoryId CategoryId, + SupplierId SupplierId, + BrandId BrandId, + string? Description = null, + IEnumerable? Images = null +) : ITxCreateCommand +{ + public long Id { get; } = SnowFlakIdGenerator.NewId(); + + /// + /// Create product with in-line validation. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static CreateProduct Of( + string? name, + decimal price, + int stock, + int restockThreshold, + int maxStockThreshold, + ProductStatus status, + int width, + int height, + int depth, + string? size, + ProductColor color, + ProductType productType, + long categoryId, + long supplierId, + long brandId, + string? description = null, + IEnumerable? images = null + ) + { + // Or we can use FluentValidation like `new CreateProductValidator()!.ValidateAndThrow(value);` for validating input here + return new CreateProduct( + Name.Of(name), + Price.Of(price), + Stock.Of(stock, restockThreshold, maxStockThreshold), + status.NotBeEmpty(), + productType, + Dimensions.Of(width, height, depth), + Size.Of(size), + color.NotBeEmpty(), + CategoryId.Of(categoryId), + SupplierId.Of(supplierId), + BrandId.Of(brandId), + description, + images + ); + } +} + +internal class CreateProductHandler : ICommandHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly ICategoryChecker _categoryChecker; + private readonly IBrandChecker _brandChecker; + private readonly ISupplierChecker _supplierChecker; + private readonly ICatalogDbContext _catalogDbContext; + + public CreateProductHandler( + ICatalogDbContext catalogDbContext, + IMapper mapper, + ICategoryChecker categoryChecker, + IBrandChecker brandChecker, + ISupplierChecker supplierChecker, + ILogger logger + ) + { + _catalogDbContext = catalogDbContext; + _mapper = mapper; + _categoryChecker = categoryChecker; + _brandChecker = brandChecker; + _supplierChecker = supplierChecker; + _logger = logger; + } + + public async Task Handle(CreateProduct command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var ( + name, + price, + stock, + status, + type, + dimensions, + size, + color, + categoryId, + supplierId, + brandId, + description, + imageItems + ) = command; + + var images = imageItems + ?.Select( + x => + new ProductImage( + EntityId.Of(SnowFlakIdGenerator.NewId()), + x.ImageUrl, + x.IsMain, + ProductId.Of(command.Id) + ) + ) + .ToList(); + + // await _domainEventDispatcher.DispatchAsync(cancellationToken, new Events.Domain.CreatingProduct()); + + // orchestration on multiple aggregate and entities in application service or handlers + var product = Product.Create( + ProductId.Of(command.Id), + name, + null, + stock, + status, + type, + dimensions, + size, + color, + description, + price, + categoryId, + supplierId, + brandId, + async cid => await _catalogDbContext.CategoryExistsAsync(cid!, cancellationToken: cancellationToken), + _supplierChecker, + _brandChecker, + images + ); + + await _catalogDbContext.Products.AddAsync(product, cancellationToken: cancellationToken); + await _catalogDbContext.SaveChangesAsync(cancellationToken); + + var created = await _catalogDbContext.Products + .Include(x => x.Brand) + .Include(x => x.Category) + .Include(x => x.Supplier) + .SingleOrDefaultAsync(x => x.Id == product.Id, cancellationToken: cancellationToken); + + _logger.LogInformation("Product a with ID: '{ProductId} created.'", created!.Id); + + return new CreateProductResult(created.Id); + } +} + +internal record CreateProductResult(long Id); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs new file mode 100644 index 00000000..6aaf9914 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs @@ -0,0 +1,96 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; +using FoodDelivery.Services.Catalogs.Products.Models; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1; + +// POST api/v1/catalog/products +internal static class CreateProductEndpoint +{ + internal static RouteHandlerBuilder MapCreateProductsEndpoint(this IEndpointRouteBuilder endpoints) + { + // return endpoints.MapCommandEndpoint< + // CreateProductRequest, + // CreateProductResponse, + // CreateProduct, + // CreateProductResult + // >("/", StatusCodes.Status201Created, getId: response => response.Id); + + // https://github.com/dotnet/aspnetcore/issues/45082 + // https://github.com/dotnet/aspnetcore/issues/40753 + // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/2414 + // https://github.com/dotnet/aspnetcore/issues/45871 + return endpoints + .MapPost("/", Handle) + .WithTags(ProductsConfigs.Tag) + .RequireAuthorization() + .WithName(nameof(CreateProduct)) + .WithDisplayName(nameof(CreateProduct).Humanize()) + .WithSummaryAndDescription(nameof(CreateProduct).Humanize(), nameof(CreateProduct).Humanize()) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces("Product created successfully.", StatusCodes.Status201Created) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem("UnAuthorized request.", StatusCodes.Status401Unauthorized) + .MapToApiVersion(1.0); + + async Task< + Results, UnAuthorizedHttpProblemResult, ValidationProblem> + > Handle([AsParameters] CreateProductRequestParameters requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = mapper.Map(request); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.CreatedAtRoute( + new CreateProductResponse(result.Id), + nameof(GetProductById), + new { id = result.Id } + ); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record CreateProductRequestParameters( + [FromBody] CreateProductRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +internal record CreateProductResponse(long Id); + +// parameters can be pass as null from the user +internal record CreateProductRequest( + string? Name, + decimal Price, + int Stock, + int RestockThreshold, + int MaxStockThreshold, + int Height, + int Width, + int Depth, + string? Size, + long CategoryId, + long SupplierId, + long BrandId, + string? Description = null, + ProductColor Color = ProductColor.Black, + ProductStatus Status = ProductStatus.Available, + ProductType ProductType = ProductType.Food, + IEnumerable? Images = null +); + +internal record CreateProductImageRequest(string ImageUrl, bool IsMain); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Domain/ProductCreated.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Domain/ProductCreated.cs new file mode 100644 index 00000000..9cbbd6ab --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Domain/ProductCreated.cs @@ -0,0 +1,145 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Data; +using FoodDelivery.Services.Catalogs.Suppliers; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our message and events (because of handling versioning in other boundaries), just primitive types +internal record ProductCreated( + long Id, + string Name, + decimal Price, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + ProductStatus Status, + int Width, + int Height, + int Depth, + string Size, + ProductColor Color, + long CategoryId, + long SupplierId, + long BrandId, + DateTime CreatedAt, + string? Description = null, + IEnumerable? Images = null +) : DomainEvent +{ + // Prevent duplicate validation with value-objects just in creation not for event itself + public static ProductCreated Of( + ProductId id, + Name name, + Price price, + Stock stock, + ProductStatus status, + Dimensions dimensions, + Size size, + ProductColor color, + CategoryId categoryId, + SupplierId supplierId, + BrandId brandId, + DateTime createdAt, + string? description = null, + IEnumerable? images = null + ) + { + return new ProductCreated( + id, + name, + price, + stock.Available, + stock.RestockThreshold, + stock.MaxStockThreshold, + status.NotBeEmpty(), + dimensions.Width, + dimensions.Height, + dimensions.Depth, + size, + color.NotBeEmpty(), + categoryId, + supplierId, + brandId, + createdAt, + description, + images + ); + } +} + +internal class ProductCreatedHandler : IDomainEventHandler +{ + private readonly CatalogDbContext _dbContext; + + public ProductCreatedHandler(CatalogDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(ProductCreated notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + + var existed = await _dbContext.ProductsView.FirstOrDefaultAsync( + x => x.ProductId == notification.Id, + cancellationToken + ); + + if (existed is null) + { + var product = await _dbContext.Products + .Include(x => x.Brand) + .Include(x => x.Category) + .Include(x => x.Supplier) + .SingleOrDefaultAsync(x => x.Id == notification.Id, cancellationToken); + + if (product is null) + { + throw new ProductNotFoundException(notification.Id); + } + + var productView = new ProductView + { + ProductId = product.Id, + ProductName = product.Name, + CategoryId = product.CategoryId, + CategoryName = product.Category?.Name ?? string.Empty, + SupplierId = product.SupplierId, + SupplierName = product.Supplier?.Name ?? string.Empty, + BrandId = product.BrandId, + BrandName = product.Brand?.Name ?? string.Empty, + }; + + await _dbContext.Set().AddAsync(productView, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + } + } +} + +// Mapping domain event to integration event in domain event handler is better from mapping in command handler (for preserving our domain rule invariants). +internal class ProductCreatedDomainEventToIntegrationMappingHandler : IDomainEventHandler +{ + public ProductCreatedDomainEventToIntegrationMappingHandler() { } + + public Task Handle(ProductCreated domainEvent, CancellationToken cancellationToken) + { + // 1. Mapping DomainEvent To IntegrationEvent + // 2. Save Integration Event to Outbox + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Notification/ProductCreatedNotification.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Notification/ProductCreatedNotification.cs new file mode 100644 index 00000000..2d36b02d --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/CreatingProduct/v1/Events/Notification/ProductCreatedNotification.cs @@ -0,0 +1,35 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Domain.Events.Internal; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Domain; + +namespace FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Notification; + +internal record ProductCreatedNotification(ProductCreated DomainEvent) + : DomainNotificationEventWrapper(DomainEvent); + +internal class ProductCreatedHandler : IDomainNotificationEventHandler +{ + private readonly IBus _bus; + + public ProductCreatedHandler(IBus bus) + { + _bus = bus; + } + + public Task Handle(ProductCreatedNotification notification, CancellationToken cancellationToken) + { + // We could publish integration event to bus here + // await _bus.PublishAsync( + // new FoodDelivery.Services.Shared.Catalogs.Products.Events.Integration.ProductCreatedV1( + // notification.InternalCommandId, + // notification.Name, + // notification.Stock, + // notification.CategoryName ?? "", + // notification.Stock), + // null, + // cancellationToken); + + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProdctStock.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProdctStock.cs new file mode 100644 index 00000000..02846b45 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProdctStock.cs @@ -0,0 +1,62 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +internal record DebitProductStock(long ProductId, int Quantity) : ITxCommand +{ + public static DebitProductStock Of(long productId, int quantity) + { + return new DebitProductStockValidator().HandleValidation(new DebitProductStock(productId, quantity)); + } +} + +internal class DebitProductStockValidator : AbstractValidator +{ + public DebitProductStockValidator() + { + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.ProductId).NotEmpty().WithMessage("ProductId must be greater than 0"); + } +} + +internal class DebitProductStockHandler : ICommandHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + + public DebitProductStockHandler(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public async Task Handle(DebitProductStock command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var (productId, quantity) = command; + + var product = await _catalogDbContext.Products.FirstOrDefaultAsync(x => x.Id == productId, cancellationToken); + + if (product is null) + throw new ProductNotFoundException(productId); + + product.DebitStock(quantity); + + await _catalogDbContext.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProductStockEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProductStockEndpoint.cs new file mode 100644 index 00000000..3b68460c --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/DebitProductStockEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1; + +// POST api/v1/catalog/products/{productId}/debit-stock +public static class DebitProductStockEndpoint +{ + internal static RouteHandlerBuilder MapDebitProductStockEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/{productId}/debit-stock", Handle) + .RequireAuthorization() + .WithTags(ProductsConfigs.Tag) + .WithName(nameof(DebitProductStock)) + .WithDisplayName(nameof(DebitProductStock).Humanize()) + .WithSummaryAndDescription(nameof(DebitProductStock).Humanize(), nameof(DebitProductStock).Humanize()) + // .Produces("Debit-Stock performed successfully. (No Content)", StatusCodes.Status204NoContent) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + // .ProducesProblem(StatusCodes.Status404NotFound) + .MapToApiVersion(1.0); + + async Task< + Results + > Handle([AsParameters] DebitProductStockRequestParameters requestParameters) + { + var (request, productId, context, commandProcessor, _, cancellationToken) = requestParameters; + + await commandProcessor.SendAsync(DebitProductStock.Of(productId, request.DebitQuantity), cancellationToken); + + return TypedResults.NoContent(); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record DebitProductStockRequestParameters( + [FromBody] DebitProductStockRequest Request, + [FromRoute] long ProductId, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +internal record DebitProductStockRequest(int DebitQuantity); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductRestockThresholdReached.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductRestockThresholdReached.cs new file mode 100644 index 00000000..dff39214 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductRestockThresholdReached.cs @@ -0,0 +1,63 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Shared.Contracts; + +namespace FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +internal record ProductRestockThresholdReached( + long ProductId, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + int Quantity +) : DomainEvent +{ + public static ProductRestockThresholdReached Of( + long productId, + int availableStock, + int restockThreshold, + int maxStockThreshold, + int quantity + ) + { + productId.NotBeNegativeOrZero(); + availableStock.NotBeNegativeOrZero(); + restockThreshold.NotBeNegativeOrZero(); + maxStockThreshold.NotBeNegativeOrZero(); + quantity.NotBeNegativeOrZero(); + + return new ProductRestockThresholdReached( + productId, + availableStock, + restockThreshold, + maxStockThreshold, + quantity + ); + } +} + +internal class ProductRestockThresholdReachedHandler : IDomainEventHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + + public ProductRestockThresholdReachedHandler(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public Task Handle(ProductRestockThresholdReached notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + + // For example send an email to get more products + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductStockDebited.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductStockDebited.cs new file mode 100644 index 00000000..abf6a2af --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/DebitingProductStock/v1/Events/Domain/ProductStockDebited.cs @@ -0,0 +1,57 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +public record ProductStockDebited( + long ProductId, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + int DebitQuantity +) : DomainEvent +{ + public static ProductStockDebited Of( + long productId, + int availableStock, + int restockThreshold, + int maxStockThreshold, + int debitQuantity + ) + { + productId.NotBeNegativeOrZero(); + availableStock.NotBeNegativeOrZero(); + restockThreshold.NotBeNegativeOrZero(); + maxStockThreshold.NotBeNegativeOrZero(); + debitQuantity.NotBeNegativeOrZero(); + + return new ProductStockDebited(productId, availableStock, restockThreshold, maxStockThreshold, debitQuantity); + + // // Also if validation rules are more complex we can use `fluentvalidation` + // return new ProductStockDebitedValidator().HandleValidation( + // new ProductStockDebited( + // productId, + // availableStock, + // restockThreshold, + // maxStockThreshold, + // debitQuantity + // ) + // ); + } +} + +internal class ProductStockDebitedHandler : IDomainEventHandler +{ + public Task Handle(ProductStockDebited notification, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingAvailableStockById/v1/GetAvailableStockById.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingAvailableStockById/v1/GetAvailableStockById.cs new file mode 100644 index 00000000..642b1188 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingAvailableStockById/v1/GetAvailableStockById.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.CQRS.Queries; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingAvailableStockById.v1; + +public record GetAvailableStockById(long ProductId) : IQuery; diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductById.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductById.cs new file mode 100644 index 00000000..dabdd0d6 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductById.cs @@ -0,0 +1,66 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Caching; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; +using FluentValidation; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; + +internal record GetProductById(long Id) : CacheQuery +{ + /// + /// GetProductById query with validation. + /// + /// + /// + public static GetProductById Of(long id) + { + return new GetProductByIdValidator().HandleValidation(new GetProductById(id)); + } + + public override string CacheKey(GetProductById request) + { + return $"{base.CacheKey(request)}_{request.Id}"; + } +} + +internal class GetProductByIdValidator : AbstractValidator +{ + public GetProductByIdValidator() + { + RuleFor(x => x.Id).GreaterThan(0); + } +} + +internal class GetProductByIdHandler : IQueryHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + private readonly IMapper _mapper; + + public GetProductByIdHandler(ICatalogDbContext catalogDbContext, IMapper mapper) + { + _catalogDbContext = catalogDbContext; + _mapper = mapper; + } + + public async Task Handle(GetProductById query, CancellationToken cancellationToken) + { + query.NotBeNull(); + + var product = await _catalogDbContext.FindProductByIdAsync(ProductId.Of(query.Id)); + if (product is null) + throw new ProductNotFoundException(query.Id); + + var productsDto = _mapper.Map(product); + + return new GetProductByIdResult(productsDto); + } +} + +internal record GetProductByIdResult(ProductDto Product); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductByIdEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductByIdEndpoint.cs new file mode 100644 index 00000000..6e6ce379 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductById/v1/GetProductByIdEndpoint.cs @@ -0,0 +1,61 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; + +// GET api/v1/catalog/products/{id} +internal static class GetProductByIdEndpoint +{ + internal static RouteHandlerBuilder MapGetProductByIdEndpoint(this IEndpointRouteBuilder app) + { + // return app.MapQueryEndpoint("/{id}") + return app.MapGet("/{id}", Handle) + // .RequireAuthorization() + .WithTags(ProductsConfigs.Tag) + .WithName(nameof(GetProductById)) + .WithDisplayName(nameof(GetProductById).Humanize()) + .WithSummaryAndDescription(nameof(GetProductById).Humanize(), nameof(GetProductById).Humanize()) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces("Product fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .MapToApiVersion(1.0); + + async Task< + Results< + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > + > Handle([AsParameters] GetProductByIdRequestParameters requestParameters) + { + var (id, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetProductById.Of(id), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetProductByIdResponse(result.Product)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetProductByIdRequestParameters( + [FromRoute] long Id, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +internal record GetProductByIdResponse(ProductDto Product); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProducts.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProducts.cs new file mode 100644 index 00000000..b5947232 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProducts.cs @@ -0,0 +1,86 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.Models.Read; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Sieve.Services; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProducts.v1; + +internal record GetProducts : PageQuery +{ + /// + /// Get Products with in-line validation. + /// + /// + /// + public static GetProducts Of(PageRequest pageRequest) + { + var (pageNumber, pageSize, filters, sortOrder) = pageRequest; + + return new GetProductsValidator().HandleValidation( + new GetProducts + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + } + ); + } +} + +internal class GetProductsValidator : AbstractValidator +{ + public GetProductsValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage("Page should at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage("PageSize should at least greater than or equal to 1."); + } +} + +internal class GetProductsHandler : IQueryHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + private readonly ISieveProcessor _sieveProcessor; + private readonly IMapper _mapper; + + public GetProductsHandler(IMapper mapper, ICatalogDbContext catalogDbContext, ISieveProcessor sieveProcessor) + { + _catalogDbContext = catalogDbContext; + _sieveProcessor = sieveProcessor; + _mapper = mapper; + } + + public async Task Handle(GetProducts request, CancellationToken cancellationToken) + { + var products = await _catalogDbContext.Products + .OrderByDescending(x => x.Created) + .AsNoTracking() + .ApplyPagingAsync( + request, + _mapper.ConfigurationProvider, + _sieveProcessor, + cancellationToken: cancellationToken + ); + + var result = products.MapTo(_mapper); + + return new GetProductsResult(result); + } +} + +internal record GetProductsResult(IPageList Products); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProductsEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProductsEndpoint.cs new file mode 100644 index 00000000..a768459b --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProducts/v1/GetProductsEndpoint.cs @@ -0,0 +1,71 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProducts.v1; + +internal static class GetProductsEndpoint +{ + internal static RouteHandlerBuilder MapGetProductsByPageEndpoint(this IEndpointRouteBuilder app) + { + // return app.MapQueryEndpoint("/") + return app.MapGet("/", Handle) + // .RequireAuthorization() + .WithTags(ProductsConfigs.Tag) + .WithName(nameof(GetProducts)) + .WithSummaryAndDescription(nameof(GetProducts).Humanize(), nameof(GetProducts).Humanize()) + .WithDisplayName(nameof(GetProducts).Humanize()) + // Api Documentations will produce automatically by typed result in minimal apis. + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces("Products fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .MapToApiVersion(1.0); + + async Task, ValidationProblem, UnAuthorizedHttpProblemResult>> Handle( + [AsParameters] GetProductsRequestParameters requestParameters + ) + { + var (context, queryProcessor, mapper, cancellationToken, _, _, _, _) = requestParameters; + + var query = GetProducts.Of( + new PageRequest + { + PageNumber = requestParameters.PageNumber, + PageSize = requestParameters.PageSize, + SortOrder = requestParameters.SortOrder, + Filters = requestParameters.SortOrder + } + ); + + var result = await queryProcessor.SendAsync(query, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetProductsResponse(result.Products)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetProductsRequestParameters( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken, + int PageSize = 10, + int PageNumber = 1, + string? Filters = null, + string? SortOrder = null +) : IHttpQuery, IPageRequest; + +internal record GetProductsResponse(IPageList Products); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsView.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsView.cs new file mode 100644 index 00000000..1028c0b8 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsView.cs @@ -0,0 +1,76 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Validation.Extensions; +using Dapper; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Models; +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProductsView.v1; + +internal record GetProductsView : PageQuery +{ + public static GetProductsView Of(PageRequest pageRequest) + { + var (pageNumber, pageSize, filters, sortOrder) = pageRequest; + + return new GetProductsViewValidator().HandleValidation( + new GetProductsView + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + } + ); + } +} + +internal class GetProductsViewValidator : AbstractValidator +{ + public GetProductsViewValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage("Page should at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage("PageSize should at least greater than or equal to 1."); + } +} + +internal class GetProductsViewHandler : IRequestHandler +{ + private readonly IDbFacadeResolver _facadeResolver; + private readonly IMapper _mapper; + + public GetProductsViewHandler(IDbFacadeResolver facadeResolver, IMapper mapper) + { + _facadeResolver = facadeResolver; + _mapper = mapper; + } + + public async Task Handle(GetProductsView request, CancellationToken cancellationToken) + { + await using var conn = _facadeResolver.Database.GetDbConnection(); + var (pageNumber, pageSize, filters, sortOrder) = request; + await conn.OpenAsync(cancellationToken); + var results = await conn.QueryAsync( + @"SELECT product_id ""InternalCommandId"", product_name ""Name"", category_name CategoryName, supplier_name SupplierName, count(*) OVER() AS ItemCount + FROM catalog.product_views LIMIT @PageSize OFFSET ((@Page - 1) * @PageSize)", + new { pageSize, pageNumber } + ); + + var productViewDtos = _mapper.Map>(results); + + return new GetProductsViewResult(productViewDtos); + } +} + +internal record GetProductsViewResult(IEnumerable Products); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsViewEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsViewEndpoint.cs new file mode 100644 index 00000000..7bd5a9ce --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/GettingProductsView/v1/GetProductsViewEndpoint.cs @@ -0,0 +1,66 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.GettingProductsView.v1; + +// GET api/v1/catalog/products +public static class GetProductsViewEndpoint +{ + internal static RouteHandlerBuilder MapGetProductsViewEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/products-view/{page}/{pageSize}", Handle) + .WithTags(ProductsConfigs.Tag) + // .RequireAuthorization() + .WithDisplayName(nameof(v1.GetProductsView).Humanize()) + .WithSummaryAndDescription(nameof(v1.GetProductsView).Humanize(), nameof(v1.GetProductsView).Humanize()) + .WithName(nameof(v1.GetProductsView)) + // .Produces(StatusCodes.Status200OK) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .MapToApiVersion(1.0); + + async Task, ValidationProblem, UnAuthorizedHttpProblemResult>> Handle( + [AsParameters] GetProductsViewRequestParameters requestParameters + ) + { + var (context, queryProcessor, mapper, cancellationToken, _, _, _, _) = requestParameters; + var query = GetProductsView.Of( + new PageRequest + { + PageNumber = requestParameters.PageNumber, + PageSize = requestParameters.PageSize, + Filters = requestParameters.Filters, + SortOrder = requestParameters.SortOrder + } + ); + + var result = await queryProcessor.SendAsync(query, cancellationToken); + + return TypedResults.Ok(new GetProductsViewResponse(result.Products.ToList())); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetProductsViewRequestParameters( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken, + int PageSize = 10, + int PageNumber = 1, + string? Filters = null, + string? SortOrder = null +) : IHttpQuery, IPageRequest; + +internal record GetProductsViewResponse(IList Products); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/Events/Domain/ProductStockReplenished.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/Events/Domain/ProductStockReplenished.cs new file mode 100644 index 00000000..56df3353 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/Events/Domain/ProductStockReplenished.cs @@ -0,0 +1,63 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +internal record ProductStockReplenished( + long ProductId, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + int ReplenishedQuantity +) : DomainEvent +{ + public static ProductStockReplenished Of( + long productId, + int availableStock, + int restockThreshold, + int maxStockThreshold, + int replenishedQuantity + ) + { + productId.NotBeNegativeOrZero(); + availableStock.NotBeNegativeOrZero(); + restockThreshold.NotBeNegativeOrZero(); + maxStockThreshold.NotBeNegativeOrZero(); + replenishedQuantity.NotBeNegativeOrZero(); + + return new ProductStockReplenished( + productId, + availableStock, + restockThreshold, + maxStockThreshold, + replenishedQuantity + ); + + // // Also if validation rules are more complex we can use `fluentvalidation` + // return new ProductStockReplenishedValidator().HandleValidation( + // new ProductStockReplenished( + // productId, + // availableStock, + // restockThreshold, + // maxStockThreshold, + // replenishedQuantity + // ) + // ); + } +} + +internal class ProductStockReplenishedHandler : IDomainEventHandler +{ + public Task Handle(ProductStockReplenished notification, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStock.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStock.cs new file mode 100644 index 00000000..0a9cff6e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStock.cs @@ -0,0 +1,61 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; +using FluentValidation; +using MediatR; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +public record ReplenishProductStock(long ProductId, int Quantity) : ITxCommand +{ + public static ReplenishProductStock Of(long productId, int quantity) + { + return new ReplenishingProductStockValidator().HandleValidation(new ReplenishProductStock(productId, quantity)); + } +} + +internal class ReplenishingProductStockValidator : AbstractValidator +{ + public ReplenishingProductStockValidator() + { + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.ProductId).NotEmpty().WithMessage("ProductId must be greater than 0"); + } +} + +internal class ReplenishingProductStockHandler : ICommandHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + + public ReplenishingProductStockHandler(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public async Task Handle(ReplenishProductStock command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var (productId, quantity) = command; + + var product = await _catalogDbContext.FindProductByIdAsync(ProductId.Of(productId)); + if (product is null) + throw new ProductNotFoundException(productId); + + product.ReplenishStock(quantity); + await _catalogDbContext.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStockEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStockEndpoint.cs new file mode 100644 index 00000000..8ea9e367 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/ReplenishingProductStock/v1/ReplenishProductStockEndpoint.cs @@ -0,0 +1,59 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1; + +// POST api/v1/catalog/products/{productId}/replenish-stock +internal static class ReplenishProductStockEndpoint +{ + internal static RouteHandlerBuilder MapReplenishProductStockEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/{productId}/replenish-stock", Handle) + .RequireAuthorization() + .WithTags(ProductsConfigs.Tag) + .WithName(nameof(ReplenishProductStock)) + .WithDisplayName(nameof(ReplenishProductStock).Humanize()) + .WithSummaryAndDescription( + nameof(ReplenishProductStock).Humanize(), + nameof(ReplenishProductStock).Humanize() + ) + // .Produces("Debit-Stock performed successfully. (No Content)", StatusCodes.Status204NoContent) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + // .ProducesProblem(StatusCodes.Status404NotFound) + .MapToApiVersion(1.0); + + async Task< + Results + > Handle([AsParameters] ReplenishProductStockRequestParameters requestParameters) + { + var (request, productId, context, commandProcessor, _, cancellationToken) = requestParameters; + + await commandProcessor.SendAsync( + ReplenishProductStock.Of(productId, request.DebitQuantity), + cancellationToken + ); + + return TypedResults.NoContent(); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record ReplenishProductStockRequestParameters( + [FromBody] ReplenishProductStockRequest Request, + [FromRoute] long ProductId, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +internal record ReplenishProductStockRequest(int DebitQuantity); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/ProductUpdated.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/ProductUpdated.cs new file mode 100644 index 00000000..5a8ab4de --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/ProductUpdated.cs @@ -0,0 +1,154 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Shared.Data; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +internal record ProductUpdated( + long Id, + string Name, + decimal Price, + int AvailableStock, + int RestockThreshold, + int MaxStockThreshold, + ProductStatus Status, + int Width, + int Height, + int Depth, + string Size, + ProductColor Color, + long CategoryId, + long SupplierId, + long BrandId, + DateTime CreatedAt, + string? Description = null, + IEnumerable? Images = null +) : DomainEvent +{ + public static ProductUpdated Of( + long id, + string? name, + decimal price, + int availableStock, + int restockThreshold, + int maxStockThreshold, + ProductStatus status, + int width, + int height, + int depth, + string size, + ProductColor color, + long categoryId, + long supplierId, + long brandId, + DateTime createdAt, + string? description = null, + IEnumerable? images = null + ) + { + return new ProductUpdatedValidator().HandleValidation( + new ProductUpdated( + id, + name!, + price, + availableStock, + restockThreshold, + maxStockThreshold, + status, + width, + height, + depth, + size, + color, + categoryId, + supplierId, + brandId, + createdAt, + description, + images + ) + ); + } +} + +internal class ProductUpdatedValidator : AbstractValidator +{ + public ProductUpdatedValidator() + { + RuleFor(x => x.Id).NotEmpty().GreaterThan(0).WithMessage("Id must be greater than 0"); + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.Price).NotEmpty().GreaterThan(0).WithMessage("Price must be greater than 0"); + RuleFor(x => x.Status).IsInEnum().WithMessage("Status is required."); + RuleFor(x => x.Color).IsInEnum().WithMessage("Color is required."); + RuleFor(x => x.AvailableStock).NotEmpty().GreaterThan(0).WithMessage("Stock must be greater than 0"); + RuleFor(x => x.MaxStockThreshold) + .NotEmpty() + .GreaterThan(0) + .WithMessage("MaxStockThreshold must be greater than 0"); + RuleFor(x => x.RestockThreshold) + .NotEmpty() + .GreaterThan(0) + .WithMessage("RestockThreshold must be greater than 0"); + RuleFor(x => x.CategoryId).NotEmpty().GreaterThan(0).WithMessage("CategoryId must be greater than 0"); + RuleFor(x => x.SupplierId).NotEmpty().GreaterThan(0).WithMessage("SupplierId must be greater than 0"); + RuleFor(x => x.BrandId).NotEmpty().GreaterThan(0).WithMessage("BrandId must be greater than 0"); + } +} + +internal class ProductUpdatedHandler : IDomainEventHandler +{ + private readonly CatalogDbContext _dbContext; + + public ProductUpdatedHandler(CatalogDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(ProductUpdated notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + + var existed = await _dbContext.ProductsView.FirstOrDefaultAsync( + x => x.ProductId == notification.Id, + cancellationToken + ); + + if (existed is not null) + { + var product = await _dbContext.Products + .Include(x => x.Brand) + .Include(x => x.Category) + .Include(x => x.Supplier) + .SingleOrDefaultAsync(x => x.Id == notification.Id, cancellationToken); + + if (product == null) + throw new ProductNotFoundException(notification.Id); + + existed.ProductId = product!.Id; + existed.ProductName = product.Name; + existed.CategoryId = product.CategoryId; + existed.CategoryName = product.Category?.Name ?? string.Empty; + existed.SupplierId = product.SupplierId; + existed.SupplierName = product.Supplier?.Name ?? string.Empty; + existed.BrandId = product.BrandId; + existed.BrandName = product.Brand?.Name ?? string.Empty; + + _dbContext.Set().Update(existed); + await _dbContext.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProduct.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProduct.cs new file mode 100644 index 00000000..cfc70604 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProduct.cs @@ -0,0 +1,190 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Caching; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FluentValidation; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Categories.Contracts; +using FoodDelivery.Services.Catalogs.Categories.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Application; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers.Exceptions.Application; +using MediatR; + +namespace FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; + +internal record UpdateProduct( + long Id, + string Name, + decimal Price, + int RestockThreshold, + int MaxStockThreshold, + ProductStatus Status, + ProductType ProductType, + ProductColor ProductColor, + int Width, + int Height, + int Depth, + string Size, + long CategoryId, + long SupplierId, + long BrandId, + string? Description = null +) : ITxCommand +{ + // Update product command with in-line validation + public static UpdateProduct Of( + long id, + string? name, + decimal price, + int restockThreshold, + int maxStockThreshold, + ProductStatus status, + ProductType productType, + ProductColor color, + int width, + int height, + int depth, + string? size, + long categoryId, + long supplierId, + long brandId, + string? description = null + ) + { + return new UpdateProductValidator().HandleValidation( + new UpdateProduct( + id, + name!, + price, + restockThreshold, + maxStockThreshold, + status, + productType, + color, + width, + height, + depth, + size!, + categoryId, + supplierId, + brandId, + description + ) + ); + } +} + +internal class UpdateProductValidator : AbstractValidator +{ + public UpdateProductValidator() + { + RuleFor(x => x.Id).NotEmpty().GreaterThan(0); + RuleFor(x => x.Id).NotEmpty().GreaterThan(0).WithMessage("Id must be greater than 0"); + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + RuleFor(x => x.Price).NotEmpty().GreaterThan(0).WithMessage("Price must be greater than 0"); + RuleFor(x => x.Status).IsInEnum().WithMessage("Status is required."); + RuleFor(x => x.MaxStockThreshold) + .NotEmpty() + .GreaterThan(0) + .WithMessage("MaxStockThreshold must be greater than 0"); + RuleFor(x => x.RestockThreshold) + .NotEmpty() + .GreaterThan(0) + .WithMessage("RestockThreshold must be greater than 0"); + RuleFor(x => x.CategoryId).NotEmpty().GreaterThan(0).WithMessage("CategoryId must be greater than 0"); + RuleFor(x => x.SupplierId).NotEmpty().GreaterThan(0).WithMessage("SupplierId must be greater than 0"); + RuleFor(x => x.BrandId).NotEmpty().GreaterThan(0).WithMessage("BrandId must be greater than 0"); + } +} + +internal class UpdateProductInvalidateCache : InvalidateCacheRequest +{ + public override IEnumerable CacheKeys(UpdateProduct request) + { + yield return $"{Prefix}{nameof(GetProductById)}_{request.Id}"; + } +} + +internal class UpdateProductCommandHandler : ICommandHandler +{ + private readonly ICatalogDbContext _catalogDbContext; + private readonly ICategoryChecker _categoryChecker; + private readonly IBrandChecker _brandChecker; + private readonly ISupplierChecker _supplierChecker; + + public UpdateProductCommandHandler( + ICatalogDbContext catalogDbContext, + ICategoryChecker categoryChecker, + IBrandChecker brandChecker, + ISupplierChecker supplierChecker) + { + _catalogDbContext = catalogDbContext; + _categoryChecker = categoryChecker; + _brandChecker = brandChecker; + _supplierChecker = supplierChecker; + } + + public async Task Handle(UpdateProduct command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var ( + id, + name, + price, + restockThreshold, + maxStockThreshold, + productStatus, + productType, + color, + width, + height, + depth, + size, + categoryId, + supplierId, + brandId, + description + ) = command; + + var product = await _catalogDbContext.FindProductByIdAsync(ProductId.Of(id)); + if (product is null) + { + throw new ProductNotFoundException(id); + } + + var category = await _catalogDbContext.FindCategoryAsync(CategoryId.Of(id)); + if (category is null) + throw new CategoryNotFoundException(categoryId); + + var brand = await _catalogDbContext.FindBrandAsync(BrandId.Of(brandId)); + if (brand is null) + throw new BrandNotFoundException(brandId); + + var supplier = await _catalogDbContext.FindSupplierByIdAsync(SupplierId.Of(supplierId)); + if (supplier is null) + throw new SupplierNotFoundException(supplierId); + + product.ChangeCategory(async cid => await _catalogDbContext.CategoryExistsAsync(cid!, cancellationToken: cancellationToken), CategoryId.Of(categoryId)); + product.ChangeBrand(_brandChecker, BrandId.Of(brandId)); + product.ChangeSupplier(_supplierChecker, SupplierId.Of(supplierId)); + + product.ChangeProductDetail(Name.Of(name), productStatus, productType, Dimensions.Of(width, height, depth), Size.Of(size), color, null, description); + product.ChangePrice(Price.Of(price)); + product.ChangeMaxStockThreshold(maxStockThreshold); + product.ChangeRestockThreshold(restockThreshold); + + await _catalogDbContext.SaveChangesAsync(cancellationToken); + return Unit.Value; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProductEndpoint.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProductEndpoint.cs new file mode 100644 index 00000000..bc3a57e0 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Features/UpdatingProduct/v1/UpdateProductEndpoint.cs @@ -0,0 +1,78 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Catalogs.Products.Models; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; + +// PUT api/v1/catalog/products/{id} +public static class UpdateProductEndpoint +{ + internal static RouteHandlerBuilder MapUpdateProductEndpoint(this IEndpointRouteBuilder endpoints) + { + // return endpoints.MapCommandEndpoint("/"); + return endpoints + .MapPost("/{id}", Handle) + .WithTags(ProductsConfigs.Tag) + .RequireAuthorization() + .WithName(nameof(UpdateProduct)) + .WithDisplayName(nameof(UpdateProduct).Humanize()) + .WithSummaryAndDescription(nameof(UpdateProduct).Humanize(), nameof(UpdateProduct).Humanize()) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces("Product updated successfully.", StatusCodes.Status204NoContent) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem("UnAuthorized request.", StatusCodes.Status401Unauthorized) + .MapToApiVersion(1.0); + + async Task> Handle( + [AsParameters] UpdateProductRequestParameters requestParameters + ) + { + var (request, id, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = mapper.Map(request); + command = command with { Id = id }; + + await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.NoContent(); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record UpdateProductRequestParameters( + [FromBody] UpdateProductRequest Request, + [FromRoute] long Id, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +// parameters can be pass as null from the user +public record UpdateProductRequest( + long Id, + string? Name, + decimal Price, + int RestockThreshold, + int MaxStockThreshold, + ProductStatus Status, + ProductType ProductType, + ProductColor ProductColor, + int Height, + int Width, + int Depth, + string? Size, + long CategoryId, + long SupplierId, + long BrandId, + string? Description +); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/MassTransitExtensions.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/MassTransitExtensions.cs new file mode 100644 index 00000000..13a10518 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/MassTransitExtensions.cs @@ -0,0 +1,40 @@ +using FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Catalogs.Products; + +public static class MassTransitExtensions +{ + internal static void AddProductPublishers(this IRabbitMqBusFactoryConfigurator cfg) + { + cfg.Message(e => e.SetEntityName($"{nameof(ProductCreatedV1).Underscore()}.input_exchange")); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + + cfg.Message( + e => e.SetEntityName($"{nameof(ProductStockDebitedV1).Underscore()}.input_exchange") + ); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + + cfg.Message( + e => e.SetEntityName($"{nameof(ProductStockReplenishedV1).Underscore()}.input_exchange") + ); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Product.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Product.cs new file mode 100644 index 00000000..dd8d57dd --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Product.cs @@ -0,0 +1,399 @@ +using System.Collections.Immutable; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingMaxThreshold.v1; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingProductBrand.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingProductCategory.v1.Events; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingProductPrice.v1; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingProductSupplier.v1.Events; +using FoodDelivery.Services.Catalogs.Products.Features.ChangingRestockThreshold.v1; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Rules; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; + +namespace FoodDelivery.Services.Catalogs.Products.Models; + +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://enterprisecraftsmanship.com/posts/link-to-an-aggregate-reference-or-id/ +// https://ardalis.com/avoid-collections-as-properties/?utm_sq=grcpqjyka3 +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://github.com/dotnet/efcore/issues/29940 +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public class Product : Aggregate +{ + private List _images = new(); + + // EF + // this constructor is needed when we have a parameter constructor that has some navigation property classes in the parameters and ef will skip it and try to find other constructor, here default constructor (maybe will fix .net 8) + private Product() { } + + public Name Name { get; private set; } = default!; + public ProductType ProductType { get; private set; } + public string? Description { get; private set; } + public Price Price { get; private set; } = default!; + public ProductInformation ProductInformation { get; private set; } + public ProductColor Color { get; private set; } + public ProductStatus ProductStatus { get; private set; } + public Category? Category { get; private set; } = default!; + public CategoryId CategoryId { get; private set; } = default!; + public SupplierId SupplierId { get; private set; } = default!; + public Supplier? Supplier { get; private set; } + public BrandId BrandId { get; private set; } = default!; + public Brand? Brand { get; private set; } + public Size Size { get; private set; } = default!; + public Stock Stock { get; set; } = default!; + public Dimensions Dimensions { get; private set; } = default!; + public IReadOnlyList? Images => _images; + + // https://github.com/ardalis/DDD-NoDuplicates + // https://stackoverflow.com/questions/66289815/dependent-entities-within-same-aggregate/66299403 + // https://stackoverflow.com/questions/66330442/ddd-and-cqrs-how-to-make-sure-if-provided-relation-id-really-exists-as-another?noredirect=1&lq=1 + // https://github.com/kgrzybek/modular-monolith-with-ddd/blob/master/src/Modules/Registrations/Domain/UserRegistrations/UserRegistration.cs#L52 + // https://enterprisecraftsmanship.com/posts/domain-vs-application-services/ + // https://event-driven.io/en/how_to_validate_business_logic/ + // https://www.dandoescode.com/blog/domain-driven-design-patterns-for-aggregate-creation-mastery + // https://www.kamilgrzybek.com/blog/posts/domain-model-validation + + public static Product Create( + ProductId? id, + Name? name, + ProductInformation? productInformation, + Stock? stock, + ProductStatus status, + ProductType type, + Dimensions? dimensions, + Size? size, + ProductColor color, + string? description, + Price? price, + CategoryId? categoryId, + SupplierId? supplierId, + BrandId? brandId, + AggregateFuncOperation? categoryChecker, + ISupplierChecker? supplierChecker, + IBrandChecker? brandChecker, + IList? images = null + ) + { + // input validation will do in the `command` and our `value objects` before arriving to entity and makes or domain cleaner, here we just do business validation + var product = new Product { Id = id.NotBeNull(), Stock = stock.NotBeNull() }; + + product.ChangeProductDetail( + name, + status, + type, + dimensions, + size, + color, + productInformation, + description + ); + + product.ChangePrice(price); + product.AddProductImages(images); + product.ChangeCategory(categoryChecker, categoryId); + product.ChangeBrand(brandChecker, brandId); + product.ChangeSupplier(supplierChecker, supplierId); + + // here we do not use auto-mapping because we want to validate the data + product.AddDomainEvents( + ProductCreated.Of( + product.Id, + product.Name, + product.Price, + product.Stock, + product.ProductStatus, + product.Dimensions, + product.Size, + product.Color, + product.CategoryId, + product.SupplierId, + product.BrandId, + DateTime.Now, + product.Description, + product.Images?.Select(x => new ProductImageDto(x.Id, x.ProductId, x.ImageUrl, x.IsMain)) + ) + ); + + return product; + } + + // https://event-driven.io/en/property-sourcing/ + // https://stackoverflow.com/questions/59558931/should-there-be-an-update-event-per-property-or-an-update-event-per-entity-with + + public void ChangeProductDetail( + Name? name, + ProductStatus status, + ProductType productType, + Dimensions? dimensions, + Size? size, + ProductColor color, + ProductInformation? productInformation, + string? description + ) + { + // input validation will do in the command and our value objects, here we just do business validation + name.NotBeNull(); + Name = name; + + status.NotBeEmpty(); + ProductStatus = status; + + productType.NotBeEmpty(); + ProductType = productType; + + dimensions.NotBeNull(); + Dimensions = dimensions; + + size.NotBeNull(); + Size = size; + + color.NotBeEmpty(); + Color = color; + + // input validation will do in the command and our value objects, here we just do business validation + productInformation.NotBeNull(); + ProductInformation = productInformation; + + Description = description; + + // ProductDetail Changed event + } + + /// + /// Change product price. + /// + /// + /// Raise a . + /// + /// The price to be changed. + public void ChangePrice(Price? price) + { + price.NotBeNull(); + if (Price == price) + return; + + Price = price; + + AddDomainEvents(ProductPriceChanged.Of(price)); + } + + /// + /// Decrements the quantity of a particular item in inventory and ensures the restockThreshold hasn't + /// been breached. If so, a RestockRequest is generated in CheckThreshold. + /// + /// The number of items to debit. + /// int: Returns the number actually removed from stock. + public int DebitStock(int quantity) + { + if (quantity < 0) + quantity *= -1; + + if (HasStock(quantity) == false) + { + throw new InsufficientStockException( + $"Empty stock, product item '{Name}' with quantity '{quantity}' is not available." + ); + } + + var (available, restockThreshold, maxStockThreshold) = Stock; + + int removed = Math.Min(quantity, available); + + Stock = Stock.Of(available - removed, restockThreshold, maxStockThreshold); + + var (newAvailable, newRestockThreshold, newMaxStockThreshold) = Stock; + + if (newAvailable <= newRestockThreshold) + { + AddDomainEvents( + ProductRestockThresholdReached.Of(Id, newAvailable, newRestockThreshold, newMaxStockThreshold, quantity) + ); + } + + AddDomainEvents(ProductStockDebited.Of(Id, newAvailable, newRestockThreshold, newMaxStockThreshold, quantity)); + + return removed; + } + + /// + /// Increments the quantity of a particular item in inventory. + /// + /// int: Returns the quantity that has been added to stock. + /// The number of items to Replenish. + public Stock ReplenishStock(int quantity) + { + var (available, restockThreshold, maxStockThreshold) = Stock; + + // we don't have enough space in the inventory + if (available + quantity > maxStockThreshold) + { + throw new MaxStockThresholdReachedException( + $"Max stock threshold has been reached. Max stock threshold is {maxStockThreshold}" + ); + } + + Stock = Stock.Of(available + quantity, restockThreshold, maxStockThreshold); + + var (newAvailable, newRestockThreshold, newMaxStockThreshold) = Stock; + + AddDomainEvents( + ProductStockReplenished.Of(Id, newAvailable, newRestockThreshold, newMaxStockThreshold, quantity) + ); + + return Stock; + } + + public Stock ChangeMaxStockThreshold(int newMaxStockThreshold) + { + var (available, restockThreshold, maxStockThreshold) = Stock; + Stock = Stock.Of(available, restockThreshold, maxStockThreshold); + + AddDomainEvents(MaxThresholdChanged.Of(Id, maxStockThreshold)); + + return Stock; + } + + public Stock ChangeRestockThreshold(int restockThreshold) + { + Stock = Stock.Of(Stock.Available, restockThreshold, Stock.MaxStockThreshold); + + AddDomainEvents(RestockThresholdChanged.Of(Id, restockThreshold)); + + return Stock; + } + + public bool HasStock(int quantity) + { + return Stock.Available >= quantity; + } + + public void Activate() => ProductStatus = ProductStatus.Available; + + public void DeActive() => ProductStatus = ProductStatus.Unavailable; + + // passing delegate like a domain service + + /// + /// Sets category. + /// + /// The checker for CategoryId + /// The categoryId to be changed. + public void ChangeCategory(AggregateFuncOperation? categoryChecker, CategoryId? categoryId) + { + CheckRule(new CategoryIdShouldExistRuleWithExceptionType(categoryChecker, categoryId)); + + CategoryId = categoryId; + + // add event to domain events list that will be raise during committing transaction + AddDomainEvents(ProductCategoryChanged.Of(categoryId, Id)); + } + + /// + /// Sets supplier. + /// + /// The supplier checker. + /// The supplierId to be changed. + public void ChangeSupplier(ISupplierChecker? supplierChecker, SupplierId? supplierId) + { + CheckRule(new SupplierShouldExistRule(supplierChecker, supplierId)); + + SupplierId = supplierId; + + AddDomainEvents(ProductSupplierChanged.Of(supplierId, Id)); + } + + /// + /// Sets brand. + /// + /// The brand checker. + /// The brandId to be changed. + public void ChangeBrand(IBrandChecker? brandChecker, BrandId? brandId) + { + CheckRule(new BrandIdShouldExistRuleWithExceptionType(brandChecker, brandId)); + + BrandId = brandId; + + AddDomainEvents(ProductBrandChanged.Of(brandId, Id)); + } + + public void AddProductImages(IList? productImages) + { + if (productImages is null) + { + _images = null!; + return; + } + + _images.AddRange(productImages); + } + + public void Deconstruct( + out long id, + out string name, + out int availableStock, + out int restockThreshold, + out int maxStockThreshold, + out ProductStatus status, + out int width, + out int height, + out int depth, + out string size, + out ProductColor color, + out string? description, + out decimal price, + out long categoryId, + out long supplierId, + out long brandId, + out IList? images + ) => + ( + id, + name, + availableStock, + restockThreshold, + maxStockThreshold, + status, + width, + height, + depth, + size, + color, + description, + price, + categoryId, + supplierId, + brandId, + images + ) = ( + Id, + Name, + Stock.Available, + Stock.RestockThreshold, + Stock.MaxStockThreshold, + ProductStatus, + Dimensions.Width, + Dimensions.Height, + Dimensions.Depth, + Size, + Color, + Description, + Price.Value, + CategoryId, + SupplierId, + BrandId, + Images?.ToImmutableList() + ); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductColor.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductColor.cs new file mode 100644 index 00000000..9a78b167 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductColor.cs @@ -0,0 +1,11 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models; + +public enum ProductColor +{ + Black, + Blue, + Red, + White, + Yellow, + Purple, +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductImage.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductImage.cs new file mode 100644 index 00000000..5471714f --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductImage.cs @@ -0,0 +1,26 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Models; + +public class ProductImage : Entity +{ + // Id will use in the url + public ProductImage(EntityId id, string imageUrl, bool isMain, ProductId productId) + { + Id = id; + ImageUrl = imageUrl; + IsMain = isMain; + ProductId = productId; + } + + // Just for EF + private ProductImage() { } + + public string ImageUrl { get; private set; } = default!; + public bool IsMain { get; private set; } + public Product Product { get; private set; } = default!; + public ProductId ProductId { get; private set; } = default!; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductStatus.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductStatus.cs new file mode 100644 index 00000000..0aee4a9b --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductStatus.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models; + +public enum ProductStatus +{ + Available = 1, + Unavailable, +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductType.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductType.cs new file mode 100644 index 00000000..591f67ca --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductType.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models; + +public enum ProductType +{ + Food, + GroceryProduct +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductView.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductView.cs new file mode 100644 index 00000000..caab3813 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/ProductView.cs @@ -0,0 +1,13 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models; + +public class ProductView +{ + public long ProductId { get; set; } + public string ProductName { get; set; } = default!; + public long CategoryId { get; set; } + public string CategoryName { get; set; } = default!; + public long SupplierId { get; set; } + public string SupplierName { get; set; } = default!; + public long BrandId { get; set; } + public string BrandName { get; set; } = default!; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductImageReadModel.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductImageReadModel.cs new file mode 100644 index 00000000..30091d13 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductImageReadModel.cs @@ -0,0 +1,9 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models.Read; + +public class ProductImageReadModel +{ + public required long Id { get; init; } + public required string ImageUrl { get; init; } + public required long ProductId { get; init; } + public bool IsMain { get; init; } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductReadModel.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductReadModel.cs new file mode 100644 index 00000000..56ea011d --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Models/Read/ProductReadModel.cs @@ -0,0 +1,25 @@ +namespace FoodDelivery.Services.Catalogs.Products.Models.Read; + +public class ProductReadModel +{ + public required long Id { get; init; } + public required string Name { get; init; } = default!; + public string? Description { get; init; } + public required decimal Price { get; init; } + public required long CategoryId { get; init; } + public required string CategoryName { get; init; } + public required long SupplierId { get; init; } + public required string SupplierName { get; init; } + public required long BrandId { get; init; } + public required string BrandName { get; init; } + public required int AvailableStock { get; init; } + public required int RestockThreshold { get; init; } + public required int MaxStockThreshold { get; init; } + public required ProductStatus ProductStatus { get; init; } + public required ProductColor ProductColor { get; init; } + public required string Size { get; init; } + public required int Height { get; init; } + public required int Width { get; init; } + public required int Depth { get; init; } + public IEnumerable? Images { get; init; } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductEventMapper.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductEventMapper.cs new file mode 100644 index 00000000..6c13674b --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductEventMapper.cs @@ -0,0 +1,106 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1.Events.Notification; +using FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1.Events.Domain; +using FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Products; + +public class ProductEventMapper : IEventMapper +{ + private readonly CatalogDbContext _catalogDbContext; + + public ProductEventMapper(CatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent domainEvent) + { + switch (domainEvent) + { + // Materialize domain event to integration event + case ProductCreated productCreated: + { + var product = _catalogDbContext.Products + .Include(x => x.Brand) + .Include(x => x.Category) + .Include(x => x.Supplier) + .FirstOrDefault(x => x.Id == productCreated.Id); + + product.NotBeNull(); + product.Category.NotBeNull(); + + return Services.Shared.Catalogs.Products.Events.v1.Integration.ProductCreatedV1.Of( + productCreated.Id, + productCreated.Name, + productCreated.CategoryId, + product.Category.Name, + productCreated.AvailableStock + ); + } + + case ProductUpdated productUpdated: + { + var product = _catalogDbContext.Products + .Include(x => x.Brand) + .Include(x => x.Category) + .Include(x => x.Supplier) + .FirstOrDefault(x => x.Id == productUpdated.Id); + + product.NotBeNull(); + product.Category.NotBeNull(); + + return Services.Shared.Catalogs.Products.Events.v1.Integration.ProductUpdatedV1.Of( + productUpdated.Id, + productUpdated.Name, + productUpdated.CategoryId, + product.Category.Name, + productUpdated.AvailableStock + ); + } + + case ProductStockDebited productStockDebited: + return Services.Shared.Catalogs.Products.Events.v1.Integration.ProductStockDebitedV1.Of( + productStockDebited.ProductId, + productStockDebited.AvailableStock, + productStockDebited.DebitQuantity + ); + case ProductStockReplenished productStockReplenished: + return Services.Shared.Catalogs.Products.Events.v1.Integration.ProductStockReplenishedV1.Of( + productStockReplenished.ProductId, + productStockReplenished.AvailableStock, + productStockReplenished.ReplenishedQuantity + ); + default: + return null; + } + } + + public IDomainNotificationEvent? MapToDomainNotificationEvent(IDomainEvent domainEvent) + { + return domainEvent switch + { + ProductCreated productCreated => new ProductCreatedNotification(productCreated), + _ => null + }; + } + + public IReadOnlyList MapToIntegrationEvents(IReadOnlyList domainEvents) + { + return domainEvents.Select(MapToIntegrationEvent).ToList().AsReadOnly(); + } + + public IReadOnlyList MapToDomainNotificationEvents( + IReadOnlyList domainEvents + ) + { + return domainEvents.Select(MapToDomainNotificationEvent).ToList().AsReadOnly(); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductMappers.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductMappers.cs new file mode 100644 index 00000000..6de196dc --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductMappers.cs @@ -0,0 +1,104 @@ +using AutoMapper; +using FoodDelivery.Services.Catalogs.Products.Dtos.v1; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProducts.v1; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductsView.v1; +using FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.Models.Read; + +namespace FoodDelivery.Services.Catalogs.Products; + +public class ProductMappers : Profile +{ + public ProductMappers() + { + CreateMap() + .ForMember(x => x.Depth, opt => opt.MapFrom(x => x.Dimensions.Depth)) + .ForMember(x => x.Height, opt => opt.MapFrom(x => x.Dimensions.Height)) + .ForMember(x => x.Width, opt => opt.MapFrom(x => x.Dimensions.Width)) + .ForMember(x => x.AvailableStock, opt => opt.MapFrom(x => x.Stock.Available)) + .ForMember(x => x.RestockThreshold, opt => opt.MapFrom(x => x.Stock.RestockThreshold)) + .ForMember(x => x.MaxStockThreshold, opt => opt.MapFrom(x => x.Stock.MaxStockThreshold)) + .ForMember(x => x.Name, opt => opt.MapFrom(x => x.Name.Value)) + .ForMember(x => x.Price, opt => opt.MapFrom(x => x.Price.Value)) + .ForMember(x => x.BrandId, opt => opt.MapFrom(x => x.BrandId.Value)) + .ForMember(x => x.BrandName, opt => opt.MapFrom(x => x.Brand == null ? "" : x.Brand.Name)) + .ForMember(x => x.CategoryName, opt => opt.MapFrom(x => x.Category == null ? "" : x.Category.Name)) + .ForMember(x => x.CategoryId, opt => opt.MapFrom(x => x.CategoryId.Value)) + .ForMember(x => x.SupplierName, opt => opt.MapFrom(x => x.Supplier == null ? "" : x.Supplier.Name)) + .ForMember(x => x.SupplierId, opt => opt.MapFrom(x => x.SupplierId.Value)) + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.ProductStatus, opt => opt.MapFrom(x => x.ProductStatus)) + .ForMember(x => x.Size, opt => opt.MapFrom(x => x.Size.Value)) + .ForMember(x => x.ProductColor, opt => opt.MapFrom(x => x.Color)) + .ForMember(x => x.Description, opt => opt.MapFrom(x => x.Description)) + .ForMember(x => x.Images, opt => opt.MapFrom(x => x.Images)); + + CreateMap() + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.ProductId, opt => opt.MapFrom(x => x.ProductId.Value)); + + CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap() + .ConstructUsing( + req => + CreateProduct.Of( + req.Name, + req.Price, + req.Stock, + req.RestockThreshold, + req.MaxStockThreshold, + req.Status, + req.Width, + req.Height, + req.Depth, + req.Size, + req.Color, + req.ProductType, + req.CategoryId, + req.SupplierId, + req.BrandId, + req.Description, + req.Images + ) + ); + + CreateMap() + .ConstructUsing( + req => + UpdateProduct.Of( + req.Id, + req.Name, + req.Price, + req.RestockThreshold, + req.MaxStockThreshold, + req.Status, + req.ProductType, + req.ProductColor, + req.Width, + req.Height, + req.Depth, + req.Size, + req.CategoryId, + req.SupplierId, + req.BrandId, + req.Description + ) + ); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductsConfigs.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductsConfigs.cs new file mode 100644 index 00000000..ccfe1014 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ProductsConfigs.cs @@ -0,0 +1,58 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Catalogs.Products.Data; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1; +using FoodDelivery.Services.Catalogs.Products.Features.DebitingProductStock.v1; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductById.v1; +using FoodDelivery.Services.Catalogs.Products.Features.GettingProductsView.v1; +using FoodDelivery.Services.Catalogs.Products.Features.ReplenishingProductStock.v1; +using FoodDelivery.Services.Catalogs.Products.Features.UpdatingProduct.v1; +using FoodDelivery.Services.Catalogs.Shared; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products; + +internal class ProductsConfigs : IModuleConfiguration +{ + public const string Tag = "Products"; + public const string ProductsPrefixUri = $"{SharedModulesConfiguration.CatalogModulePrefixUri}/products"; + + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.Services.TryAddScoped(); + builder.Services.TryAddSingleton(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + // https://github.com/dotnet/aspnet-api-versioning/commit/b789e7e980e83a7d2f82ce3b75235dee5e0724b4 + // changed from MapApiGroup to NewVersionedApi in v7.0.0 + var routeCategoryName = Tag; + var products = endpoints.NewVersionedApi(name: routeCategoryName).WithTags(Tag); + + // create a new sub group for each version + var productsV1 = products.MapGroup(ProductsPrefixUri).HasDeprecatedApiVersion(0.9).HasApiVersion(1.0); + + // create a new sub group for each version + var productsV2 = products.MapGroup(ProductsPrefixUri).HasApiVersion(2.0); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-7.0#route-groups + // https://github.com/dotnet/aspnet-api-versioning/blob/main/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs + productsV1.MapCreateProductsEndpoint(); + productsV1.MapUpdateProductEndpoint(); + productsV1.MapDebitProductStockEndpoint(); + productsV1.MapReplenishProductStockEndpoint(); + productsV1.MapGetProductByIdEndpoint(); + productsV1.MapGetProductsViewEndpoint(); + + return endpoints; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/BrandIdShouldExistRuleWithExceptionType.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/BrandIdShouldExistRuleWithExceptionType.cs new file mode 100644 index 00000000..7e0ff463 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/BrandIdShouldExistRuleWithExceptionType.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.Exceptions.Domain; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; + +namespace FoodDelivery.Services.Catalogs.Products.Rules; + +public class BrandIdShouldExistRuleWithExceptionType : IBusinessRuleWithExceptionType +{ + private readonly IBrandChecker? _brandChecker; + private readonly BrandId? _id; + + public BrandIdShouldExistRuleWithExceptionType([NotNull] IBrandChecker? brandChecker, [NotNull] BrandId? id) + { + _brandChecker = brandChecker; + _id = id; + } + + public bool IsBroken() + { + _brandChecker.NotBeNull(); + _id.NotBeNull(); + var exists = _brandChecker.BrandExists(_id); + + return !exists; + } + + public BrandNotFoundException Exception => new(GetType(), _id!); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/CategoryIdShouldExistRuleWithExceptionType.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/CategoryIdShouldExistRuleWithExceptionType.cs new file mode 100644 index 00000000..b3d2756e --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/CategoryIdShouldExistRuleWithExceptionType.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Categories.Exceptions.Domain; +using FoodDelivery.Services.Catalogs.Shared.Contracts; + +namespace FoodDelivery.Services.Catalogs.Products.Rules; + +public class CategoryIdShouldExistRuleWithExceptionType : IBusinessRuleWithExceptionType +{ + private readonly AggregateFuncOperation? _categoryChecker; + private readonly CategoryId? _id; + + public CategoryIdShouldExistRuleWithExceptionType( + [NotNull] AggregateFuncOperation? categoryChecker, + [NotNull] CategoryId? id + ) + { + _categoryChecker = categoryChecker; + _id = id; + } + + public bool IsBroken() + { + _categoryChecker.NotBeNull(); + _id.NotBeNull(); + var exists = _categoryChecker(_id).GetAwaiter().GetResult(); + + return !exists; + } + + public CategoryNotFoundException Exception => new(GetType(), _id!); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/SupplierShouldExistRule.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/SupplierShouldExistRule.cs new file mode 100644 index 00000000..6f6787a7 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/Rules/SupplierShouldExistRule.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; + +namespace FoodDelivery.Services.Catalogs.Products.Rules; + +public class SupplierShouldExistRule : IBusinessRule +{ + private readonly ISupplierChecker? _supplierChecker; + private readonly SupplierId? _id; + + public SupplierShouldExistRule([NotNull] ISupplierChecker? supplierChecker, [NotNull] SupplierId? id) + { + _supplierChecker = supplierChecker; + _id = id; + } + + public bool IsBroken() + { + _supplierChecker.NotBeNull(); + _id.NotBeNull(); + var exists = _supplierChecker.SupplierExists(_id); + + return !exists; + } + + public string Message => $"Supplier with id {_id} not exist."; + + public int Status => StatusCodes.Status404NotFound; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Dimensions.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Dimensions.cs new file mode 100644 index 00000000..5fc9d33a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Dimensions.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://github.com/NimblePros/ValueObjects +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record Dimensions +{ + // EF + private Dimensions() { } + + public int Height { get; private set; } + public int Width { get; private set; } + public int Depth { get; private set; } + + public static Dimensions Of(int width, int height, int depth) + { + height.NotBeNegativeOrZero(); + width.NotBeNegativeOrZero(); + depth.NotBeNegativeOrZero(); + + return new Dimensions + { + Height = height, + Width = width, + Depth = depth, + }; + } + + public static Dimensions Of([NotNull] int? width, [NotNull] int? height, [NotNull] int? depth) + { + height.NotBeNull(); + width.NotBeNull(); + depth.NotBeNull(); + + return Of(width.Value, height.Value, depth.Value); + } + + public void Deconstruct(out int width, out int height, out int depth) => + (width, height, depth) = (Width, Height, Depth); + + public override string ToString() + { + return FormattedDescription(); + } + + private string FormattedDescription() + { + return $"HxWxD: {Height} x {Width} x {Depth}"; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Name.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Name.cs new file mode 100644 index 00000000..5e24b061 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Name.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://enterprisecraftsmanship.com/posts/functional-c-primitive-obsession/ +public record Name +{ + // EF + private Name() { } + + public string Value { get; private set; } = default!; + + public static Name Of([NotNull] string? value) + { + // validations should be placed here instead of constructor + value.NotBeNullOrWhiteSpace(); + + return new Name { Value = value }; + } + + public static implicit operator string(Name value) => value.Value; + + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Price.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Price.cs new file mode 100644 index 00000000..d5f45c75 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Price.cs @@ -0,0 +1,41 @@ +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record Price +{ + // EF + private Price(decimal value) + { + Value = value; + } + + // Note: in entities with none default constructor, for setting constructor parameter, we need a private set property + // when we didn't define this property in fluent configuration mapping (if so we can remove private set) , because for getting mapping list of properties to set + // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties + public decimal Value { get; private set; } + + public static Price Of(decimal value) + { + value.NotBeNegativeOrZero(); + + // validations should be placed here instead of constructor + return new Price(value); + } + + public static Price Of([NotNull] decimal? value) + { + value.NotBeNull(); + + return Of(value.Value); + } + + public static implicit operator decimal(Price value) => value.Value; + + public void Deconstruct(out decimal value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductId.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductId.cs new file mode 100644 index 00000000..cecfec79 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductId.cs @@ -0,0 +1,19 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record ProductId : AggregateId +{ + // EF + private ProductId(long value) + : base(value) { } + + // validations should be placed here instead of constructor + public static ProductId Of(long id) => new(id.NotBeNegativeOrZero()); + + public static implicit operator long(ProductId id) => id.Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductInformation.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductInformation.cs new file mode 100644 index 00000000..3b896c47 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/ProductInformation.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +public record ProductInformation +{ + // EF + private ProductInformation() { } + + public string Title { get; private set; } = default!; + public string Content { get; private set; } = default!; + + public static ProductInformation Of([NotNull] string? title, [NotNull] string? content) + { + // validations should be placed here instead of constructor + title.NotBeNullOrWhiteSpace(); + content.NotBeNullOrWhiteSpace(); + + return new ProductInformation { Title = title, Content = content }; + } + + public void Deconstruct(out string title, out string content) => (title, content) = (Title, Content); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Size.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Size.cs new file mode 100644 index 00000000..9458d6d9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Size.cs @@ -0,0 +1,32 @@ +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record Size +{ + private Size(string value) + { + Value = value; + } + + // Note: in entities with none default constructor, for setting constructor parameter, we need a private set property + // when we didn't define this property in fluent configuration mapping (if so we can remove private set) , because for getting mapping list of properties to set + // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties + public string Value { get; private set; } + + public static Size Of([NotNull] string? value) + { + // validations should be placed here instead of constructor + value.NotBeNullOrWhiteSpace(); + return new Size(value); + } + + public static implicit operator string(Size value) => value.Value; + + public void Deconstruct(out string value) => value = Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Stock.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Stock.cs new file mode 100644 index 00000000..882a104f --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/Stock.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record Stock +{ + // EF + private Stock() { } + + /// + /// Gets quantity in stock. + /// + public int Available { get; private set; } + + /// + /// Gets available stock at which we should reorder. + /// + public int RestockThreshold { get; private set; } + + /// + /// Gets maximum number of units that can be in-stock at any time (due to physicial/logistical constraints in warehouses). + /// + public int MaxStockThreshold { get; private set; } + + public static Stock Of(int available, int restockThreshold, int maxStockThreshold) + { + // validations should be placed here instead of constructor + available.NotBeNegativeOrZero(); + restockThreshold.NotBeNegativeOrZero(); + maxStockThreshold.NotBeNegativeOrZero(); + + var stock = new Stock + { + Available = available, + RestockThreshold = restockThreshold, + MaxStockThreshold = maxStockThreshold, + }; + + if (stock.Available > stock.MaxStockThreshold) + throw new MaxStockThresholdReachedException("Available stock cannot be greater than max stock threshold."); + + return stock; + } + + public static Stock Of([NotNull] int? available, [NotNull] int? restockThreshold, [NotNull] int? maxStockThreshold) + { + available.NotBeNull(); + restockThreshold.NotBeNull(); + maxStockThreshold.NotBeNull(); + + return Of(available.Value, restockThreshold.Value, maxStockThreshold.Value); + } + + public void Deconstruct(out int available, out int restockThreshold, out int maxStockThreshold) => + (available, restockThreshold, maxStockThreshold) = (Available, RestockThreshold, MaxStockThreshold); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/SupplierInformation.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/SupplierInformation.cs new file mode 100644 index 00000000..86fa3cbf --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Products/ValueObjects/SupplierInformation.cs @@ -0,0 +1,26 @@ +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Products.Exceptions.Domain; +using FoodDelivery.Services.Catalogs.Suppliers; + +namespace FoodDelivery.Services.Catalogs.Products.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public record SupplierInformation +{ + // EF + private SupplierInformation() { } + + public Name Name { get; private set; } = default!; + public SupplierId Id { get; private set; } = default!; + + public static SupplierInformation Of(SupplierId? id, Name? name) + { + // validations should be placed here instead of constructor + id.NotBeNull(); + name.NotBeNull(); + + return new SupplierInformation { Id = id, Name = name }; + } + + public void Deconstruct(out Name name, out SupplierId supplierId) => (name, supplierId) = (Name, Id); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/AggregateFuncOperation.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/AggregateFuncOperation.cs new file mode 100644 index 00000000..74bc2ef9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/AggregateFuncOperation.cs @@ -0,0 +1,5 @@ +namespace FoodDelivery.Services.Catalogs.Shared.Contracts; + +public delegate Task AggregateFuncOperation(T input); + +public delegate Task AggregateActionOperation(T input); diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/ICatalogDbContext.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/ICatalogDbContext.cs new file mode 100644 index 00000000..40583013 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Contracts/ICatalogDbContext.cs @@ -0,0 +1,21 @@ +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Suppliers; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Shared.Contracts; + +public interface ICatalogDbContext +{ + DbSet Products { get; } + DbSet Categories { get; } + DbSet Brands { get; } + DbSet Suppliers { get; } + DbSet ProductsView { get; } + + DbSet Set() + where TEntity : class; + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogDbContext.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogDbContext.cs new file mode 100644 index 00000000..f2c2fdf9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogDbContext.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CatalogDbContext : EfDbContextBase, ICatalogDbContext +{ + public const string DefaultSchema = "catalog"; + + public CatalogDbContext(DbContextOptions options) + : base(options) { } + + public DbSet Products { get; set; } = default!; + public DbSet ProductsView { get; set; } = default!; + public DbSet Categories { get; set; } = default!; + public DbSet Suppliers { get; set; } = default!; + public DbSet Brands { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogReadDbContext.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogReadDbContext.cs new file mode 100644 index 00000000..45f842c9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogReadDbContext.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Catalogs.Products.Models.Read; +using Humanizer; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CatalogReadDbContext : MongoDbContext +{ + public CatalogReadDbContext(IOptions options) + : base(options.Value) + { + Products = GetCollection(nameof(Products).Underscore()); + } + + public IMongoCollection Products { get; } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsDataSeeder.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsDataSeeder.cs new file mode 100644 index 00000000..55679349 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsDataSeeder.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CatalogsDataSeeder : IDataSeeder +{ + public int Order => 5; + + public Task SeedAllAsync() + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsMigrationExecutor.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsMigrationExecutor.cs new file mode 100644 index 00000000..9e9d2048 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/CatalogsMigrationExecutor.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Abstractions.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CatalogsMigrationExecutor : IMigrationExecutor +{ + private readonly CatalogDbContext _catalogDbContext; + private readonly ILogger _logger; + + public CatalogsMigrationExecutor(CatalogDbContext catalogDbContext, ILogger logger) + { + _catalogDbContext = catalogDbContext; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migration worker started"); + + _logger.LogInformation("Updating catalog database..."); + + await _catalogDbContext.Database.MigrateAsync(cancellationToken: cancellationToken); + + _logger.LogInformation("catalog database Updated"); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/DbContextDesignFactory.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/DbContextDesignFactory.cs new file mode 100644 index 00000000..6cd2855a --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/DbContextDesignFactory.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Persistence.EfCore.Postgres; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CatalogDbContextDesignFactory : DbContextDesignFactoryBase +{ + public CatalogDbContextDesignFactory() + : base("PostgresOptions:ConnectionString") { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.Designer.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.Designer.cs new file mode 100644 index 00000000..aca76c87 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.Designer.cs @@ -0,0 +1,627 @@ +// +using System; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Catalogs.Shared.Data.Migrations.Catalogs +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20240719224742_InitialCatalogMigration")] + partial class InitialCatalogMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Brands.Brand", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_brands"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_brands_id"); + + b.ToTable("brands", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_categories_id"); + + b.ToTable("categories", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.CategoryImage", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("image_url"); + + b.HasKey("Id") + .HasName("pk_category_image"); + + b.HasIndex("CategoryId") + .IsUnique() + .HasDatabaseName("ix_category_image_category_id"); + + b.ToTable("category_image", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("brand_id"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("Color") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Black") + .HasColumnName("color"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.Property("ProductStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Available") + .HasColumnName("product_status"); + + b.Property("ProductType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Food") + .HasColumnName("product_type"); + + b.Property("SupplierId") + .HasColumnType("bigint") + .HasColumnName("supplier_id"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("BrandId") + .HasDatabaseName("ix_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_products_category_id"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_products_id"); + + b.HasIndex("SupplierId") + .HasDatabaseName("ix_products_supplier_id"); + + b.ToTable("products", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductImage", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("image_url"); + + b.Property("IsMain") + .HasColumnType("boolean") + .HasColumnName("is_main"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("product_id"); + + b.HasKey("Id") + .HasName("pk_product_images"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_product_images_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_product_images_product_id"); + + b.ToTable("product_images", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductView", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("product_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProductId")); + + b.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("brand_id"); + + b.Property("BrandName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("brand_name"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("CategoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category_name"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_name"); + + b.Property("SupplierId") + .HasColumnType("bigint") + .HasColumnName("supplier_id"); + + b.Property("SupplierName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("supplier_name"); + + b.HasKey("ProductId") + .HasName("pk_product_views"); + + b.HasIndex("ProductId") + .IsUnique() + .HasDatabaseName("ix_product_views_product_id"); + + b.ToTable("product_views", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Suppliers.Supplier", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_suppliers"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_suppliers_id"); + + b.ToTable("suppliers", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Brands.Brand", b => + { + b.OwnsOne("FoodDelivery.Services.Catalogs.Brands.ValueObjects.BrandName", "Name", b1 => + { + b1.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("BrandId"); + + b1.ToTable("brands", "catalog"); + + b1.WithOwner() + .HasForeignKey("BrandId") + .HasConstraintName("fk_brands_brands_id"); + }); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.OwnsOne("FoodDelivery.Services.Catalogs.Categories.ValueObjects.CategoryCode", "Code", b1 => + { + b1.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b1.HasKey("CategoryId"); + + b1.ToTable("categories", "catalog"); + + b1.WithOwner() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_categories_categories_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Categories.ValueObjects.CategoryName", "Name", b1 => + { + b1.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("CategoryId"); + + b1.ToTable("categories", "catalog"); + + b1.WithOwner() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_categories_categories_id"); + }); + + b.Navigation("Code") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.CategoryImage", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Categories.Category", "Category") + .WithOne("Image") + .HasForeignKey("FoodDelivery.Services.Catalogs.Categories.CategoryImage", "CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_category_image_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Brands.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_brands_brand_id"); + + b.HasOne("FoodDelivery.Services.Catalogs.Categories.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_categories_category_id"); + + b.HasOne("FoodDelivery.Services.Catalogs.Suppliers.Supplier", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_suppliers_supplier_id"); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Dimensions", "Dimensions", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Depth") + .HasColumnType("integer") + .HasColumnName("dimensions_depth"); + + b1.Property("Height") + .HasColumnType("integer") + .HasColumnName("dimensions_height"); + + b1.Property("Width") + .HasColumnType("integer") + .HasColumnName("dimensions_width"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Name", "Name", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Price", "Price", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("price"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.ProductInformation", "ProductInformation", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_content"); + + b1.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_title"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Size", "Size", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("size"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Stock", "Stock", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Available") + .HasColumnType("integer") + .HasColumnName("stock_available"); + + b1.Property("MaxStockThreshold") + .HasColumnType("integer") + .HasColumnName("stock_max_stock_threshold"); + + b1.Property("RestockThreshold") + .HasColumnType("integer") + .HasColumnName("stock_restock_threshold"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("Dimensions") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + + b.Navigation("ProductInformation") + .IsRequired(); + + b.Navigation("Size") + .IsRequired(); + + b.Navigation("Stock") + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductImage", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Products.Models.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_images_products_product_id"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.Navigation("Image") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.cs new file mode 100644 index 00000000..0ccc9da8 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/20240719224742_InitialCatalogMigration.cs @@ -0,0 +1,291 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Catalogs.Shared.Data.Migrations.Catalogs +{ + /// + public partial class InitialCatalogMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "catalog"); + + migrationBuilder.CreateTable( + name: "brands", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + name = table.Column(type: "text", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_brands", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "categories", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + name = table.Column(type: "text", nullable: false), + code = table.Column(type: "text", nullable: false), + description = table.Column(type: "text", nullable: true), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_categories", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "product_views", + schema: "catalog", + columns: table => new + { + product_id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + product_name = table.Column(type: "text", nullable: false), + category_id = table.Column(type: "bigint", nullable: false), + category_name = table.Column(type: "text", nullable: false), + supplier_id = table.Column(type: "bigint", nullable: false), + supplier_name = table.Column(type: "text", nullable: false), + brand_id = table.Column(type: "bigint", nullable: false), + brand_name = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_product_views", x => x.product_id); + }); + + migrationBuilder.CreateTable( + name: "suppliers", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + name = table.Column(type: "varchar(50)", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_suppliers", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "category_image", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + image_url = table.Column(type: "text", nullable: false), + category_id = table.Column(type: "bigint", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_category_image", x => x.id); + table.ForeignKey( + name: "fk_category_image_categories_category_id", + column: x => x.category_id, + principalSchema: "catalog", + principalTable: "categories", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "products", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + name = table.Column(type: "text", nullable: false), + product_type = table.Column(type: "character varying(25)", maxLength: 25, nullable: false, defaultValue: "Food"), + description = table.Column(type: "text", nullable: true), + price = table.Column(type: "numeric(18,2)", nullable: false), + product_information_title = table.Column(type: "text", nullable: false), + product_information_content = table.Column(type: "text", nullable: false), + color = table.Column(type: "character varying(25)", maxLength: 25, nullable: false, defaultValue: "Black"), + product_status = table.Column(type: "character varying(25)", maxLength: 25, nullable: false, defaultValue: "Available"), + category_id = table.Column(type: "bigint", nullable: false), + supplier_id = table.Column(type: "bigint", nullable: false), + brand_id = table.Column(type: "bigint", nullable: false), + size = table.Column(type: "text", nullable: false), + stock_available = table.Column(type: "integer", nullable: false), + stock_restock_threshold = table.Column(type: "integer", nullable: false), + stock_max_stock_threshold = table.Column(type: "integer", nullable: false), + dimensions_height = table.Column(type: "integer", nullable: false), + dimensions_width = table.Column(type: "integer", nullable: false), + dimensions_depth = table.Column(type: "integer", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_products", x => x.id); + table.ForeignKey( + name: "fk_products_brands_brand_id", + column: x => x.brand_id, + principalSchema: "catalog", + principalTable: "brands", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_products_categories_category_id", + column: x => x.category_id, + principalSchema: "catalog", + principalTable: "categories", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_products_suppliers_supplier_id", + column: x => x.supplier_id, + principalSchema: "catalog", + principalTable: "suppliers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "product_images", + schema: "catalog", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + image_url = table.Column(type: "text", nullable: false), + is_main = table.Column(type: "boolean", nullable: false), + product_id = table.Column(type: "bigint", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_product_images", x => x.id); + table.ForeignKey( + name: "fk_product_images_products_product_id", + column: x => x.product_id, + principalSchema: "catalog", + principalTable: "products", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_brands_id", + schema: "catalog", + table: "brands", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_categories_id", + schema: "catalog", + table: "categories", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_category_image_category_id", + table: "category_image", + column: "category_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_product_images_id", + schema: "catalog", + table: "product_images", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_product_images_product_id", + schema: "catalog", + table: "product_images", + column: "product_id"); + + migrationBuilder.CreateIndex( + name: "ix_product_views_product_id", + schema: "catalog", + table: "product_views", + column: "product_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_products_brand_id", + schema: "catalog", + table: "products", + column: "brand_id"); + + migrationBuilder.CreateIndex( + name: "ix_products_category_id", + schema: "catalog", + table: "products", + column: "category_id"); + + migrationBuilder.CreateIndex( + name: "ix_products_id", + schema: "catalog", + table: "products", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_products_supplier_id", + schema: "catalog", + table: "products", + column: "supplier_id"); + + migrationBuilder.CreateIndex( + name: "ix_suppliers_id", + schema: "catalog", + table: "suppliers", + column: "id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "category_image"); + + migrationBuilder.DropTable( + name: "product_images", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "product_views", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "products", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "brands", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "categories", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "suppliers", + schema: "catalog"); + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/CatalogDbContextModelSnapshot.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/CatalogDbContextModelSnapshot.cs new file mode 100644 index 00000000..daf551c0 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Data/Migrations/Catalogs/CatalogDbContextModelSnapshot.cs @@ -0,0 +1,624 @@ +// +using System; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Catalogs.Shared.Data.Migrations.Catalogs +{ + [DbContext(typeof(CatalogDbContext))] + partial class CatalogDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Brands.Brand", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_brands"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_brands_id"); + + b.ToTable("brands", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_categories_id"); + + b.ToTable("categories", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.CategoryImage", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("image_url"); + + b.HasKey("Id") + .HasName("pk_category_image"); + + b.HasIndex("CategoryId") + .IsUnique() + .HasDatabaseName("ix_category_image_category_id"); + + b.ToTable("category_image", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("brand_id"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("Color") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Black") + .HasColumnName("color"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.Property("ProductStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Available") + .HasColumnName("product_status"); + + b.Property("ProductType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasDefaultValue("Food") + .HasColumnName("product_type"); + + b.Property("SupplierId") + .HasColumnType("bigint") + .HasColumnName("supplier_id"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("BrandId") + .HasDatabaseName("ix_products_brand_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_products_category_id"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_products_id"); + + b.HasIndex("SupplierId") + .HasDatabaseName("ix_products_supplier_id"); + + b.ToTable("products", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductImage", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("ImageUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("image_url"); + + b.Property("IsMain") + .HasColumnType("boolean") + .HasColumnName("is_main"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("product_id"); + + b.HasKey("Id") + .HasName("pk_product_images"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_product_images_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_product_images_product_id"); + + b.ToTable("product_images", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductView", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("product_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProductId")); + + b.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("brand_id"); + + b.Property("BrandName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("brand_name"); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("category_id"); + + b.Property("CategoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category_name"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_name"); + + b.Property("SupplierId") + .HasColumnType("bigint") + .HasColumnName("supplier_id"); + + b.Property("SupplierName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("supplier_name"); + + b.HasKey("ProductId") + .HasName("pk_product_views"); + + b.HasIndex("ProductId") + .IsUnique() + .HasDatabaseName("ix_product_views_product_id"); + + b.ToTable("product_views", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Suppliers.Supplier", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_suppliers"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_suppliers_id"); + + b.ToTable("suppliers", "catalog"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Brands.Brand", b => + { + b.OwnsOne("FoodDelivery.Services.Catalogs.Brands.ValueObjects.BrandName", "Name", b1 => + { + b1.Property("BrandId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("BrandId"); + + b1.ToTable("brands", "catalog"); + + b1.WithOwner() + .HasForeignKey("BrandId") + .HasConstraintName("fk_brands_brands_id"); + }); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.OwnsOne("FoodDelivery.Services.Catalogs.Categories.ValueObjects.CategoryCode", "Code", b1 => + { + b1.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b1.HasKey("CategoryId"); + + b1.ToTable("categories", "catalog"); + + b1.WithOwner() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_categories_categories_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Categories.ValueObjects.CategoryName", "Name", b1 => + { + b1.Property("CategoryId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("CategoryId"); + + b1.ToTable("categories", "catalog"); + + b1.WithOwner() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_categories_categories_id"); + }); + + b.Navigation("Code") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.CategoryImage", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Categories.Category", "Category") + .WithOne("Image") + .HasForeignKey("FoodDelivery.Services.Catalogs.Categories.CategoryImage", "CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_category_image_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Brands.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_brands_brand_id"); + + b.HasOne("FoodDelivery.Services.Catalogs.Categories.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_categories_category_id"); + + b.HasOne("FoodDelivery.Services.Catalogs.Suppliers.Supplier", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_suppliers_supplier_id"); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Dimensions", "Dimensions", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Depth") + .HasColumnType("integer") + .HasColumnName("dimensions_depth"); + + b1.Property("Height") + .HasColumnType("integer") + .HasColumnName("dimensions_height"); + + b1.Property("Width") + .HasColumnType("integer") + .HasColumnName("dimensions_width"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Name", "Name", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Price", "Price", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("price"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.ProductInformation", "ProductInformation", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_content"); + + b1.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_title"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Size", "Size", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("size"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Catalogs.Products.ValueObjects.Stock", "Stock", b1 => + { + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Available") + .HasColumnType("integer") + .HasColumnName("stock_available"); + + b1.Property("MaxStockThreshold") + .HasColumnType("integer") + .HasColumnName("stock_max_stock_threshold"); + + b1.Property("RestockThreshold") + .HasColumnType("integer") + .HasColumnName("stock_restock_threshold"); + + b1.HasKey("ProductId"); + + b1.ToTable("products", "catalog"); + + b1.WithOwner() + .HasForeignKey("ProductId") + .HasConstraintName("fk_products_products_id"); + }); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("Dimensions") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + + b.Navigation("ProductInformation") + .IsRequired(); + + b.Navigation("Size") + .IsRequired(); + + b.Navigation("Stock") + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.ProductImage", b => + { + b.HasOne("FoodDelivery.Services.Catalogs.Products.Models.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_images_products_product_id"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Categories.Category", b => + { + b.Navigation("Image") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Catalogs.Products.Models.Product", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/CatalogDbContextExtensions.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/CatalogDbContextExtensions.cs new file mode 100644 index 00000000..d1a0c893 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/CatalogDbContextExtensions.cs @@ -0,0 +1,88 @@ +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Shared.Extensions; + +/// +/// Put some shared code between multiple feature here, for preventing duplicate some codes +/// Ref: https://www.youtube.com/watch?v=01lygxvbao4. +/// +public static class CatalogDbContextExtensions +{ + public static Task ProductExistsAsync( + this ICatalogDbContext context, + ProductId id, + CancellationToken cancellationToken = default + ) + { + return context.Products.AnyAsync(x => x.Id == id, cancellationToken: cancellationToken); + } + + public static ValueTask FindProductByIdAsync(this ICatalogDbContext context, ProductId id) + { + return context.Products.FindAsync(id); + } + + public static Task SupplierExistsAsync( + this ICatalogDbContext context, + SupplierId id, + CancellationToken cancellationToken = default + ) + { + return context.Suppliers.AnyAsync(x => x.Id == id, cancellationToken: cancellationToken); + } + + public static ValueTask FindSupplierByIdAsync(this ICatalogDbContext context, SupplierId id) + { + return context.Suppliers.FindAsync(id); + } + + public static Supplier? FindSupplierById(this ICatalogDbContext context, SupplierId id) + { + return context.Suppliers.Find(id); + } + + public static Task CategoryExistsAsync( + this ICatalogDbContext context, + CategoryId id, + CancellationToken cancellationToken = default + ) + { + return context.Categories.AnyAsync(x => x.Id == id, cancellationToken: cancellationToken); + } + + public static ValueTask FindCategoryAsync(this ICatalogDbContext context, CategoryId id) + { + return context.Categories.FindAsync(id); + } + + public static Category? FindCategory(this ICatalogDbContext context, CategoryId id) + { + return context.Categories.Find(id); + } + + public static Task BrandExistsAsync( + this ICatalogDbContext context, + BrandId id, + CancellationToken cancellationToken = default + ) + { + return context.Brands.AnyAsync(x => x.Id == id, cancellationToken: cancellationToken); + } + + public static ValueTask FindBrandAsync(this ICatalogDbContext context, BrandId id) + { + return context.Brands.FindAsync(id); + } + + public static Brand? FindBrand(this ICatalogDbContext context, BrandId id) + { + return context.Brands.Find(id); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs new file mode 100644 index 00000000..9e984386 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -0,0 +1,129 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Caching.Behaviours; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Email; +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Integration.MassTransit; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.OpenTelemetry; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Security.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Swagger; +using BuildingBlocks.Validation; +using BuildingBlocks.Validation.Extensions; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Catalogs.Products; + +namespace FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder) + { + builder.Services.AddCore(); + + builder.Services.AddCustomJwtAuthentication(builder.Configuration); + builder.Services.AddCustomAuthorization( + rolePolicies: new List + { + new(CatalogConstants.Role.Admin, new List { CatalogConstants.Role.Admin }), + new(CatalogConstants.Role.User, new List { CatalogConstants.Role.User }) + } + ); + + builder.Services.AddEmailService(builder.Configuration); + builder.Services.AddCqrs( + pipelines: new[] + { + typeof(StreamLoggingBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(RequestValidationBehavior<,>), + typeof(StreamRequestValidationBehavior<,>), + typeof(StreamCachingBehavior<,>), + typeof(CachingBehavior<,>), + typeof(InvalidateCachingBehavior<,>), + typeof(EfTxBehavior<,>) + } + ); + + // https://github.com/tonerdo/dotnet-env + DotNetEnv.Env.TraversePath().Load(); + + // https://www.thorsten-hans.com/hot-reload-net-configuration-in-kubernetes-with-configmaps/ + // https://bartwullems.blogspot.com/2021/03/kubernetesoverride-appsettingsjson-file.html + // if we need to reload app by change settings, we can use volume map for watching our new setting from config-files folder in the the volume or change appsettings.json file in the volume map + var configFolder = builder.Configuration.GetValue("ConfigurationFolder") ?? "config-files/"; + builder.Configuration.AddKeyPerFile(configFolder, true, true); + + // https://www.michaco.net/blog/EnvironmentVariablesAndConfigurationInASPNETCoreApps#environment-variables-and-configuration + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#non-prefixed-environment-variables + builder.Configuration.AddEnvironmentVariables("food_delivery_catalogs_env_"); + + builder.AddCustomVersioning(); + + builder.AddCustomSwagger(); + + builder.AddCustomCors(); + + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddPostgresMessagePersistence(builder.Configuration); + + builder.AddCompression(); + + builder.AddAppProblemDetails(); + + builder.AddCustomSerilog(); + + builder.AddCustomOpenTelemetry(); + + // https://blog.maartenballiauw.be/post/2022/09/26/aspnet-core-rate-limiting-middleware.html + builder.AddCustomRateLimit(); + + builder.AddCustomMassTransit( + (busRegistrationContext, busFactoryConfigurator) => + { + busFactoryConfigurator.AddProductPublishers(); + } + ); + + if (builder.Environment.IsTest() == false) + { + builder.AddCustomHealthCheck(healthChecksBuilder => + { + var postgresOptions = builder.Configuration.BindOptions(nameof(PostgresOptions)); + var rabbitMqOptions = builder.Configuration.BindOptions(nameof(RabbitMqOptions)); + + postgresOptions.NotBeNull(); + rabbitMqOptions.NotBeNull(); + + healthChecksBuilder + .AddNpgSql( + postgresOptions.ConnectionString, + name: "CatalogsService-Postgres-Check", + tags: new[] { "postgres", "infra", "database", "catalogs-service", "live", "ready" } + ) + .AddRabbitMQ( + rabbitMqOptions.ConnectionString, + name: "CatalogsService-RabbitMQ-Check", + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "rabbitmq", "infra", "bus", "catalogs-service", "live", "ready" } + ); + }); + } + + builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); + builder.Services.AddAutoMapper(x => + { + x.AddProfile(); + }); + + builder.AddCustomEasyCaching(); + + return builder; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs new file mode 100644 index 00000000..e223296c --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs @@ -0,0 +1,51 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddStorage(this WebApplicationBuilder builder) + { + AddPostgresWriteStorage(builder.Services, builder.Configuration); + AddMongoReadStorage(builder.Services, builder.Configuration); + + return builder; + } + + private static void AddPostgresWriteStorage(IServiceCollection services, IConfiguration configuration) + { + var option = configuration.BindOptions(); + if (option.UseInMemory) + { + services.AddDbContext( + options => options.UseInMemoryDatabase("FoodDelivery.Services.Catalogs") + ); + + services.TryAddScoped(provider => provider.GetService()!); + services.TryAddScoped(provider => provider.GetService()!); + } + else + { + services.AddPostgresDbContext(configuration); + + // add migrations and seeders dependencies, or we could add seeders inner each modules + services.TryAddScoped(); + services.TryAddScoped(); + } + + services.TryAddScoped(provider => provider.GetRequiredService()); + } + + private static void AddMongoReadStorage(IServiceCollection services, IConfiguration configuration) + { + services.AddMongoDbContext(configuration); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs new file mode 100644 index 00000000..c6235de1 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Web.Problem; +using Microsoft.AspNetCore.Diagnostics; + +namespace FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddAppProblemDetails(this WebApplicationBuilder builder) + { + builder.Services.AddCustomProblemDetails(problemDetailsOptions => + { + // customization problem details should go here + problemDetailsOptions.CustomizeProblemDetails = problemDetailContext => + { + // with help of capture exception middleware for capturing actual exception + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + if (problemDetailContext.HttpContext.Features.Get() is { } exceptionFeature) + { } + }; + }); + + return builder; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs new file mode 100644 index 00000000..9256f26b --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs @@ -0,0 +1,67 @@ +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; +using BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; +using Serilog; + +namespace FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationExtensions; + +public static partial class WebApplicationExtensions +{ + public static async Task UseInfrastructure(this WebApplication app) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling + // Does nothing if a response body has already been provided. when our next `DeveloperExceptionMiddleware` is written response for exception (in dev mode) when we back to `ExceptionHandlerMiddlewareImpl` because `context.Response.HasStarted` it doesn't do anything + // By default `ExceptionHandlerMiddlewareImpl` middleware register original exceptions with `IExceptionHandlerFeature` feature, we don't have this in `DeveloperExceptionPageMiddleware` and we should handle it with a middleware like `CaptureExceptionMiddleware` + // Just for handling exceptions in production mode + // https://github.com/dotnet/aspnetcore/pull/26567 + app.UseExceptionHandler(new ExceptionHandlerOptions { AllowStatusCode404Response = true }); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment() || app.Environment.IsTest()) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/handle-errrors + app.UseDeveloperExceptionPage(); + + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + app.UseCaptureException(); + } + + // this middleware should be first middleware + // request logging just log in information level and above as default + app.UseSerilogRequestLogging(opts => + { + opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest; + + // this level wil use for request logging + // https://andrewlock.net/using-serilog-aspnetcore-in-asp-net-core-3-excluding-health-check-endpoints-from-serilog-request-logging/#customising-the-log-level-used-for-serilog-request-logs + opts.GetLevel = LogEnricher.GetLogLevel; + }); + + app.UseRequestLogContextMiddleware(); + + app.UseCustomCors(); + + app.UseAuthentication(); + app.UseAuthorization(); + + await app.UsePostgresPersistenceMessage(app.Logger); + + await app.MigrateDatabases(); + + app.UseCustomRateLimit(); + + if (app.Environment.IsTest() == false) + app.UseCustomHealthCheck(); + + // Configure the prometheus endpoint for scraping metrics + // NOTE: This should only be exposed on an internal port! + // .RequireHost("*:9100"); + app.MapPrometheusScrapingEndpoint(); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Migration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Migration.cs new file mode 100644 index 00000000..6040cbd9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/Extensions/WebApplicationExtensions/Migration.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Catalogs; + +public static partial class WebApplicationExtensions +{ + public static async Task MigrateDatabases(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var migrationManager = scope.ServiceProvider.GetRequiredService(); + + await migrationManager.ExecuteAsync(CancellationToken.None); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/SharedModulesConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/SharedModulesConfiguration.cs new file mode 100644 index 00000000..48146add --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Shared/SharedModulesConfiguration.cs @@ -0,0 +1,51 @@ +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core; +using FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationBuilderExtensions; +using FoodDelivery.Services.Catalogs.Shared.Extensions.WebApplicationExtensions; + +namespace FoodDelivery.Services.Catalogs.Shared; + +public class SharedModulesConfiguration : ISharedModulesConfiguration +{ + public const string CatalogModulePrefixUri = "api/v{version:apiVersion}/catalogs"; + + public IEndpointRouteBuilder MapSharedModuleEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints + .MapGet( + "/", + (HttpContext context) => + { + var requestId = context.Request.Headers.TryGetValue( + "X-Request-InternalCommandId", + out var requestIdHeader + ) + ? requestIdHeader.FirstOrDefault() + : string.Empty; + + return $"Catalogs Service Apis, RequestId: {requestId}"; + } + ) + .ExcludeFromDescription(); + + return endpoints; + } + + public WebApplicationBuilder AddSharedModuleServices(WebApplicationBuilder builder) + { + builder.AddInfrastructure(); + + builder.AddStorage(); + + return builder; + } + + public async Task ConfigureSharedModule(WebApplication app) + { + await app.UseInfrastructure(); + + ServiceActivator.Configure(app.Services); + + return app; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Configs.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Configs.cs new file mode 100644 index 00000000..51185476 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Configs.cs @@ -0,0 +1,29 @@ +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers.Data; +using FoodDelivery.Services.Catalogs.Suppliers.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.Suppliers; + +internal class Configs : IModuleConfiguration +{ + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + return endpoints; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Contracts/ISupplierChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Contracts/ISupplierChecker.cs new file mode 100644 index 00000000..335aa0b4 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Contracts/ISupplierChecker.cs @@ -0,0 +1,6 @@ +namespace FoodDelivery.Services.Catalogs.Suppliers.Contracts; + +public interface ISupplierChecker +{ + bool SupplierExists(SupplierId supplierId); +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierDataSeeder.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierDataSeeder.cs new file mode 100644 index 00000000..61d972a9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierDataSeeder.cs @@ -0,0 +1,30 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Data; + +public class SupplierDataSeeder : IDataSeeder +{ + private readonly ICatalogDbContext _dbContext; + + public SupplierDataSeeder(ICatalogDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task SeedAllAsync() + { + if (await _dbContext.Suppliers.AnyAsync()) + return; + + var suppliers = new SupplierFaker().Generate(5); + await _dbContext.Suppliers.AddRangeAsync(suppliers); + + await _dbContext.SaveChangesAsync(); + } + + public int Order => 2; +} + +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with CustomInstantiator diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierEntityTypeConfiguration.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierEntityTypeConfiguration.cs new file mode 100644 index 00000000..8a067581 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierEntityTypeConfiguration.cs @@ -0,0 +1,22 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Data; + +public class SupplierEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Supplier).Pluralize().Underscore(), CatalogDbContext.DefaultSchema); + + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + builder.Property(x => x.Name).HasColumnType(EfConstants.ColumnTypes.NormalText).IsRequired(); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierFaker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierFaker.cs new file mode 100644 index 00000000..8b7407ed --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Data/SupplierFaker.cs @@ -0,0 +1,25 @@ +using Bogus; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Data; + +public sealed class SupplierFaker : Faker +{ + public SupplierFaker() + { + long id = 1; + + // https://www.youtube.com/watch?v=T9pwE1GAr_U + // https://jackhiston.com/2017/10/1/how-to-create-bogus-data-in-c/ + // https://khalidabuhakmeh.com/seed-entity-framework-core-with-bogus + // https://github.com/bchavez/Bogus#bogus-api-support + // https://github.com/bchavez/Bogus/blob/master/Examples/EFCoreSeedDb/Program.cs#L74 + + // Call for objects that have complex initialization + // faker doesn't work with normal syntax because it has no default constructor + CustomInstantiator(faker => + { + var supplier = new Supplier(SupplierId.Of(id++), faker.Person.FullName); + return supplier; + }); + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Application/SupplierNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Application/SupplierNotFoundException.cs new file mode 100644 index 00000000..563f8af9 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Application/SupplierNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Exceptions.Application; + +public class SupplierNotFoundException : NotFoundAppException +{ + public SupplierNotFoundException(long id) + : base($"Supplier with id '{id}' not found") { } + + public SupplierNotFoundException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierDomainException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierDomainException.cs new file mode 100644 index 00000000..35db4935 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierDomainException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Exceptions.Domain; + +public class SupplierDomainException : DomainException +{ + public SupplierDomainException(string message) + : base(message) { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierNotFoundException.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierNotFoundException.cs new file mode 100644 index 00000000..b1f4320c --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Exceptions/Domain/SupplierNotFoundException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Exceptions.Domain; + +public class SupplierNotFoundException : NotFoundDomainException +{ + public SupplierNotFoundException(Type businessRuleType, long id) + : base(businessRuleType, $"Supplier with id '{id}' not found.") { } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierCreated/Events/Integration/External/SupplierCreated.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierCreated/Events/Integration/External/SupplierCreated.cs new file mode 100644 index 00000000..1e0c39c4 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierCreated/Events/Integration/External/SupplierCreated.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.Domain.Events; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Features.SupplierCreated.Events.Integration.External; + +public class SupplierCreatedConsumer + : IEventHandler +{ + public Task Handle( + FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration.SupplierCreatedV1 notification, + CancellationToken cancellationToken + ) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierDeleted/Events/Integration/External/SupplierDeleted.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierDeleted/Events/Integration/External/SupplierDeleted.cs new file mode 100644 index 00000000..00a8e756 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierDeleted/Events/Integration/External/SupplierDeleted.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.Domain.Events; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Features.SupplierDeleted.Events.Integration.External; + +public class SupplierDeletedConsumer + : IEventHandler +{ + public Task Handle( + FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration.SupplierDeletedV1 notification, + CancellationToken cancellationToken + ) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierUpdated/Events/Integration/External/SupplierUpdated.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierUpdated/Events/Integration/External/SupplierUpdated.cs new file mode 100644 index 00000000..a2efe4d2 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Features/SupplierUpdated/Events/Integration/External/SupplierUpdated.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Abstractions.Domain.Events; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Features.SupplierUpdated.Events.Integration.External; + +public class SupplierUpdatedConsumer + : IEventHandler +{ + public Task Handle( + FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration.SupplierUpdatedV1 notification, + CancellationToken cancellationToken + ) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Services/SupplierChecker.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Services/SupplierChecker.cs new file mode 100644 index 00000000..ccd679f1 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Services/SupplierChecker.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Shared.Contracts; +using FoodDelivery.Services.Catalogs.Shared.Extensions; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; + +namespace FoodDelivery.Services.Catalogs.Suppliers.Services; + +public class SupplierChecker : ISupplierChecker +{ + private readonly ICatalogDbContext _catalogDbContext; + + public SupplierChecker(ICatalogDbContext catalogDbContext) + { + _catalogDbContext = catalogDbContext; + } + + public bool SupplierExists(SupplierId supplierId) + { + supplierId.NotBeNull(); + var category = _catalogDbContext.FindSupplierById(supplierId); + + return category is not null; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Supplier.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Supplier.cs new file mode 100644 index 00000000..294fbe83 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/Supplier.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Domain; + +namespace FoodDelivery.Services.Catalogs.Suppliers; + +public class Supplier : Entity +{ + public string Name { get; } + + public Supplier(SupplierId id, string name) + { + Name = name; + Id = id; + } +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/SupplierId.cs b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/SupplierId.cs new file mode 100644 index 00000000..6dacc812 --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/Suppliers/SupplierId.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Catalogs.Suppliers; + +public record SupplierId : AggregateId +{ + // EF + private SupplierId(long value) + : base(value) { } + + // validations should be placed here instead of constructor + public static SupplierId Of(long id) => new(id.NotBeNegativeOrZero()); + + public static implicit operator long(SupplierId id) => id.Value; +} diff --git a/src/Services/Catalogs/FoodDelivery.Services.Catalogs/readme.md b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/readme.md new file mode 100644 index 00000000..36a6926f --- /dev/null +++ b/src/Services/Catalogs/FoodDelivery.Services.Catalogs/readme.md @@ -0,0 +1,6 @@ +#### Migration Scripts + +```bash +dotnet ef migrations add InitialCatalogMigration -o Shared/Data/Migrations/Catalogs -c CatalogDbContext +dotnet ef database update -c CatalogDbContext +``` diff --git a/src/Services/Catalogs/dev.Dockerfile b/src/Services/Catalogs/dev.Dockerfile new file mode 100644 index 00000000..0c9a14c4 --- /dev/null +++ b/src/Services/Catalogs/dev.Dockerfile @@ -0,0 +1,109 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Catalogs/Directory.Build.props ./Services/Catalogs/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ + +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs/FoodDelivery.Services.Catalogs.csproj ./Services/Catalogs/FoodDelivery.Services.Catalogs/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Catalogs.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN --mount=type=cache,id=catalogs_nuget,target=/root/.nuget/packages \ + dotnet restore ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/FoodDelivery.Services.Catalogs.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs/ ./Services/Catalogs/FoodDelivery.Services.Catalogs/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ + +RUN --mount=type=cache,id=catalogs_nuget,target=/root/.nuget/packages\ + dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN --mount=type=cache,id=catalogs_nuget,target=/root/.nuget/packages\ + dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +# https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Catalogs.Api.dll"] diff --git a/src/Services/Catalogs/migrations.bat b/src/Services/Catalogs/migrations.bat new file mode 100644 index 00000000..d73d801c --- /dev/null +++ b/src/Services/Catalogs/migrations.bat @@ -0,0 +1,5 @@ + +IF "%1"=="init-context" dotnet ef migrations add InitialCatalogMigration -o \FoodDelivery.Services.Catalogs\Shared\Data\Migrations\Catalogs --project .\FoodDelivery.Services.Catalogs\FoodDelivery.Services.Catalogs.csproj -c CatalogDbContext --verbose & goto exit +IF "%1"=="update-context" dotnet ef database update -c CatalogDbContext --verbose --project .\FoodDelivery.Services.Catalogs\FoodDelivery.Services.Catalogs.csproj & goto exit + +:exit \ No newline at end of file diff --git a/src/Services/Catalogs/nuget.config b/src/Services/Catalogs/nuget.config new file mode 100644 index 00000000..6ce97590 --- /dev/null +++ b/src/Services/Catalogs/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Catalogs/watch.Dockerfile b/src/Services/Catalogs/watch.Dockerfile new file mode 100644 index 00000000..d7da2176 --- /dev/null +++ b/src/Services/Catalogs/watch.Dockerfile @@ -0,0 +1,50 @@ +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilation + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as builder + +WORKDIR /src + +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Catalogs/Directory.Build.props ./Services/Catalogs/ + +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ ./Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ +COPY ./src/Services/Catalogs/FoodDelivery.Services.Catalogs/ ./Services/Catalogs/FoodDelivery.Services.Catalogs/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Catalogs/FoodDelivery.Services.Catalogs.Api/ + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +RUN dotnet watch run FoodDelivery.Services.Catalogs.Api.csproj --launch-profile Catalogs.Api.LiveRecompilation diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/FoodDelivery.Services.Checkouts.Api.csproj b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/FoodDelivery.Services.Checkouts.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/FoodDelivery.Services.Checkouts.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Program.cs b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Properties/launchSettings.json b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Properties/launchSettings.json new file mode 100644 index 00000000..91375ee8 --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8039", + "sslPort": 44350 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7107;http://localhost:5184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.Development.json b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.json b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts/Class1.cs b/src/Services/Checkouts/FoodDelivery.Services.Checkouts/Class1.cs new file mode 100644 index 00000000..8d6ae395 --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Checkouts; + +public class Class1 { } diff --git a/src/Services/Checkouts/FoodDelivery.Services.Checkouts/FoodDelivery.Services.Checkouts.csproj b/src/Services/Checkouts/FoodDelivery.Services.Checkouts/FoodDelivery.Services.Checkouts.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Checkouts/FoodDelivery.Services.Checkouts/FoodDelivery.Services.Checkouts.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Checkouts/readme.md b/src/Services/Checkouts/readme.md new file mode 100644 index 00000000..60380da3 --- /dev/null +++ b/src/Services/Checkouts/readme.md @@ -0,0 +1,3 @@ +# Checkout Microservice + +This microservices allows users to checkout their cart items and process the payment through billing microservice. \ No newline at end of file diff --git a/src/Services/Customers/Directory.Build.props b/src/Services/Customers/Directory.Build.props new file mode 100644 index 00000000..0a47decd --- /dev/null +++ b/src/Services/Customers/Directory.Build.props @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Customers/Dockerfile b/src/Services/Customers/Dockerfile new file mode 100644 index 00000000..ccdfddba --- /dev/null +++ b/src/Services/Customers/Dockerfile @@ -0,0 +1,100 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Customers/Directory.Build.props ./Services/Customers/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj ./Services/Customers/FoodDelivery.Services.Customers/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj ./Services/Customers/FoodDelivery.Services.Customers.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Customers.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN dotnet restore ./Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers.Api/ ./Services/Customers/FoodDelivery.Services.Customers.Api/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers/ ./Services/Customers/FoodDelivery.Services.Customers/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Customers/FoodDelivery.Services.Customers.Api/ + +RUN dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Customers.Api.dll"] diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/CustomersApiMetadata.cs b/src/Services/Customers/FoodDelivery.Services.Customers.Api/CustomersApiMetadata.cs new file mode 100644 index 00000000..86ac4c50 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/CustomersApiMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Api; + +public class CustomersApiMetadata { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj b/src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj new file mode 100644 index 00000000..de1d8ee7 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj @@ -0,0 +1,34 @@ + + + + true + + + + + + + + customers + dev + mcr.microsoft.com/dotnet/aspnet:latest + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs new file mode 100644 index 00000000..21701943 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs @@ -0,0 +1,88 @@ +using Bogus; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Web; +using BuildingBlocks.Swagger; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Modules.Extensions; +using FoodDelivery.Services.Customers; +using Spectre.Console; + +AnsiConsole.Write( + new FigletText("Customers Service").Centered().Color(Color.FromInt32(new Faker().Random.Int(1, 255))) +); + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis +// https://benfoster.io/blog/mvc-to-minimal-apis-aspnet-6/ +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider( + (context, options) => + { + var isDevMode = + context.HostingEnvironment.IsDevelopment() + || context.HostingEnvironment.IsTest() + || context.HostingEnvironment.IsStaging(); + + // Handling Captive Dependency Problem + // https://ankitvijay.net/2020/03/17/net-core-and-di-beware-of-captive-dependency/ + // https://levelup.gitconnected.com/top-misconceptions-about-dependency-injection-in-asp-net-core-c6a7afd14eb4 + // https://blog.ploeh.dk/2014/06/02/captive-dependency/ + // https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/web-host?view=aspnetcore-7.0&viewFallbackFrom=aspnetcore-2.2#scope-validation + // CreateDefaultBuilder and WebApplicationBuilder in minimal apis sets `ServiceProviderOptions.ValidateScopes` and `ServiceProviderOptions.ValidateOnBuild` to true if the app's environment is Development. + // check dependencies are used in a valid life time scope + options.ValidateScopes = isDevMode; + // validate dependencies on the startup immediately instead of waiting for using the service - Issue with masstransit #85 + // options.ValidateOnBuild = isDevMode; + } +); + +// https://www.talkingdotnet.com/disable-automatic-model-state-validation-in-asp-net-core-2-1/ +builder.Services.Configure(options => +{ + options.SuppressModelStateInvalidFilter = true; +}); + +builder.Services.AddValidatedOptions(); + +// register endpoints +builder.AddMinimalEndpoints(typeof(CustomersMetadata).Assembly); + +/*----------------- Module Services Setup ------------------*/ +builder.AddModulesServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsTest()) +{ + app.Services.ValidateDependencies( + builder.Services, + typeof(CustomersMetadata).Assembly, + Assembly.GetExecutingAssembly() + ); +} + +/*----------------- Module Middleware Setup ------------------*/ +await app.ConfigureModules(); + +// https://thecodeblogger.com/2021/05/27/asp-net-core-web-application-routing-and-endpoint-internals/ +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#routing-basics +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#endpoints +// https://stackoverflow.com/questions/57846127/what-are-the-differences-between-app-userouting-and-app-useendpoints +// in .net 6 and above we don't need UseRouting and UseEndpoints but if ordering is important we should write it +// app.UseRouting(); + +/*----------------- Module Routes Setup ------------------*/ +app.MapModulesEndpoints(); + +// map registered minimal endpoints +app.MapMinimalEndpoints(); + +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("docker")) +{ + // swagger middleware should register last to discover all endpoints and its versions correctly + app.UseCustomSwagger(); +} + +await app.RunAsync(); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/Properties/launchSettings.json b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Properties/launchSettings.json new file mode 100644 index 00000000..3a6c4b50 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Properties/launchSettings.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemaecommerce.org/launchsettings.json", + "profiles": { + "Customers.Api.Http": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadProfile": "aspnetcore", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Customers.Api.Https": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadProfile": "aspnetcore", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:8001;http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Customers.Api.Watch": { + "commandName": "Executable", + "executablePath": "dotnet", + "workingDirectory": "$(ProjectDir)", + "hotReloadEnabled": true, + "hotReloadProfile": "aspnetcore", + "commandLineArgs": "watch -lp Customers.Api.Http", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Customers.Api.LiveRecompilation": { + "commandName": "Project", + "dotnetRunMessages": true, + "hotReloadProfile": "aspnetcore", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.development.json b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.development.json new file mode 100644 index 00000000..f9580005 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.development.json @@ -0,0 +1,15 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + } + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json new file mode 100644 index 00000000..a8d26914 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.docker.json @@ -0,0 +1,42 @@ +{ + "AppOptions": { + "Name": "Customers Api", + "Description": "Customers Api", + "ApiAddress": "http://localhost:6000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@mongo:27017", + "DatabaseName": "food-delivery-services-customers" + }, + "PostgresOptions": { + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_customers;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "RabbitMqOptions": { + "Host": "rabbitmq", + "UserName": "guest", + "Password": "guest" + }, + "IdentityApiClientOptions": { + "BaseApiAddress": "http://identity:80", + "UsersEndpoint": "api/v1/identity/users" + }, + "CatalogsApiClientOptions": { + "BaseApiAddress": "http://catalogs:80", + "ProductsEndpoint": "api/v1/catalogs/products" + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_customers;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.json b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.json new file mode 100644 index 00000000..222729bb --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.json @@ -0,0 +1,89 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning", + "MassTransit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "AppOptions": { + "Name": "Customers Api", + "Description": "Customers Api", + "ApiAddress": "http://localhost:8000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@localhost:27017", + "DatabaseName": "food-delivery-services-customers" + }, + "PostgresOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_customers;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "RabbitMqOptions": { + "Host": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest" + }, + "IdentityApiClientOptions": { + "BaseApiAddress": "http://localhost:7000", + "UsersEndpoint": "api/v1/identity/users" + }, + "CatalogsApiClientOptions": { + "BaseApiAddress": "http://localhost:4000", + "ProductsEndpoint": "api/v1/catalogs/products" + }, + "PolicyOptions": { + "RetryCount": 3, + "BreakDuration": 30, + "TimeOutDuration": 15 + }, + "EmailOptions": { + "From": "info@my-food-delivery-service.com", + "Enable": true, + "DisplayName": "Food Delivery Application Mail", + "MimeKitOptions": { + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "", + "Password": "" + } + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_customers;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + }, + "CacheOptions": { + "ExpirationTime": 360 + }, + "HealthOptions": { + "Enabled": false + }, + "ConfigurationFolder": "config-files/" +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.test.json b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.test.json new file mode 100644 index 00000000..19071281 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/appsettings.test.json @@ -0,0 +1,11 @@ +{ + "MongoContainerOptions": { + "DatabaseName": "food-delivery-services-customers_test", + "ImageName": "mongo:latest" + }, + "PostgresContainerOptions": { + "DatabaseName": "food_delivery_services_customers_test", + "ImageName": "postgres", + "MigrationAssembly": "FoodDelivery.Services.Customers" + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/readme.md b/src/Services/Customers/FoodDelivery.Services.Customers.Api/readme.md new file mode 100644 index 00000000..5adbff27 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/readme.md @@ -0,0 +1,7 @@ +# Customer Service Api + +## Docker + +``` cmd +dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer -c Release --self-contained true -p:PublishSingleFile=true +``` \ No newline at end of file diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersConfigs.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersConfigs.cs new file mode 100644 index 00000000..8295c1d6 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersConfigs.cs @@ -0,0 +1,38 @@ +using Asp.Versioning.Builder; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Customers.Customers.Data; +using FoodDelivery.Services.Customers.Shared; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Customers.Customers; + +internal class CustomersConfigs : IModuleConfiguration +{ + public const string Tag = "Customers"; + public const string CustomersPrefixUri = $"{SharedModulesConfiguration.CustomerModulePrefixUri}"; + public static ApiVersionSet VersionSet { get; private set; } = default!; + + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.Services.TryAddScoped(); + + //// we could add event mappers manually, also they can find automatically by scanning assemblies + // builder.Services.TryAddSingleton(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + VersionSet = endpoints.NewApiVersionSet(Tag).Build(); + + // Here we can add endpoints manually but, if our endpoint inherits from `IMinimalEndpointDefinition`, they discover automatically. + return endpoints; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersEventMapper.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersEventMapper.cs new file mode 100644 index 00000000..c0c307df --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersEventMapper.cs @@ -0,0 +1,39 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; + +namespace FoodDelivery.Services.Customers.Customers; + +public class CustomersEventMapper : IIntegrationEventMapper +{ + public IReadOnlyList MapToIntegrationEvents(IReadOnlyList domainEvents) + { + return domainEvents.Select(MapToIntegrationEvent).ToList(); + } + + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent domainEvent) + { + return domainEvent switch + { + TestDomainEvent e => new TestIntegration(e.Data), + CustomerCreated customerCreated + => Services.Shared.Customers.Customers.Events.v1.Integration.CustomerCreatedV1.Of(customerCreated.Id), + CustomerUpdated customerCreated + => Services.Shared.Customers.Customers.Events.v1.Integration.CustomerUpdatedV1.Of( + customerCreated.Id, + customerCreated.FirstName, + customerCreated.LastName, + customerCreated.Email, + customerCreated.PhoneNumber, + customerCreated.IdentityId, + customerCreated.CreatedAt, + customerCreated.BirthDate, + customerCreated.Nationality, + customerCreated.DetailAddress + ), + _ => null + }; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs new file mode 100644 index 00000000..b8d6f80d --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs @@ -0,0 +1,105 @@ +using AutoMapper; +using BuildingBlocks.Core.Domain.ValueObjects; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using Customer = FoodDelivery.Services.Customers.Customers.Models.Reads.Customer; + +namespace FoodDelivery.Services.Customers.Customers; + +public class CustomersMapping : Profile +{ + public CustomersMapping() + { + CreateMap() + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.Id)) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)) + .ForMember(x => x.Name, opt => opt.MapFrom(x => x.FullName)) + .ForMember(x => x.CreatedAt, opt => opt.MapFrom(x => x.Created)) + .ForMember(x => x.Country, opt => opt.MapFrom(x => x.Country)) + .ForMember(x => x.City, opt => opt.MapFrom(x => x.City)) + .ForMember(x => x.DetailAddress, opt => opt.MapFrom(x => x.DetailAddress)) + .ForMember(x => x.Nationality, opt => opt.MapFrom(x => x.Nationality)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email)) + .ForMember(x => x.BirthDate, opt => opt.MapFrom(x => x.BirthDate)) + .ForMember(x => x.PhoneNumber, opt => opt.MapFrom(x => x.PhoneNumber)); + + CreateMap() + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.Created, opt => opt.MapFrom(x => x.Created)) + .ForMember(x => x.Country, opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.City)) + .ForMember(x => x.City, opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.City)) + .ForMember( + x => x.DetailAddress, + opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.Detail) + ) + .ForMember(x => x.Nationality, opt => opt.MapFrom(x => x.Nationality == null ? null : x.Nationality!.Value)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email.Value)) + .ForMember( + x => x.BirthDate, + opt => opt.MapFrom(x => x.BirthDate == null ? null : x.BirthDate!.Value as DateTime?) + ) + .ForMember(x => x.PhoneNumber, opt => opt.MapFrom(x => x.PhoneNumber == null ? "" : x.PhoneNumber!.Value)) + .ForMember(x => x.FirstName, opt => opt.MapFrom(x => x.Name.FirstName)) + .ForMember(x => x.LastName, opt => opt.MapFrom(x => x.Name.LastName)) + .ForMember(x => x.FullName, opt => opt.MapFrom(x => x.Name.FullName)) + .ForMember(x => x.InternalCommandId, opt => opt.Ignore()) + .ForMember(x => x.OccurredOn, opt => opt.MapFrom(x => x.Created)); + + CreateMap() + .ForMember(x => x.Id, opt => opt.Ignore()) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)); + + CreateMap() + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.Id, opt => opt.Ignore()) + .ForMember(x => x.Country, opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.City)) + .ForMember(x => x.City, opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.City)) + .ForMember( + x => x.DetailAddress, + opt => opt.MapFrom(x => x.Address == Address.Empty ? "" : x.Address!.Detail) + ) + .ForMember(x => x.Nationality, opt => opt.MapFrom(x => x.Nationality == null ? null : x.Nationality!.Value)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email.Value)) + .ForMember( + x => x.BirthDate, + opt => opt.MapFrom(x => x.BirthDate == null ? null : x.BirthDate!.Value as DateTime?) + ) + .ForMember(x => x.PhoneNumber, opt => opt.MapFrom(x => x.PhoneNumber == null ? "" : x.PhoneNumber!.Value)) + .ForMember(x => x.FirstName, opt => opt.MapFrom(x => x.Name.FirstName)) + .ForMember(x => x.LastName, opt => opt.MapFrom(x => x.Name.LastName)) + .ForMember(x => x.FullName, opt => opt.MapFrom(x => x.Name.FullName)) + .ForMember(x => x.InternalCommandId, opt => opt.Ignore()) + .ForMember(x => x.OccurredOn, opt => opt.MapFrom(x => x.Created)); + + CreateMap() + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.Id)) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)) + .ForMember(x => x.Created, opt => opt.MapFrom(x => x.OccurredOn)); + + CreateMap(); + + CreateMap(); + + CreateMap() + .ConstructUsing( + req => + UpdateCustomer.Of( + 0, + req.FirstName, + req.LastName, + req.Email, + req.PhoneNumber, + req.BirthDate, + req.Address, + req.Nationality + ) + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomerEntityTypeConfiguration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomerEntityTypeConfiguration.cs new file mode 100644 index 00000000..09423a03 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomerEntityTypeConfiguration.cs @@ -0,0 +1,94 @@ +using BuildingBlocks.Core.Domain.ValueObjects; +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Customers.Customers.Data; + +public class CustomerEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Customer).Pluralize().Underscore(), CustomersDbContext.DefaultSchema); + + // ids will use strongly typed-id value converter selector globally + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.Property(x => x.IdentityId); + builder.HasIndex(x => x.IdentityId).IsUnique(); + + builder.OwnsOne( + x => x.Email, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Customer.Email).Underscore()) + .IsRequired() + .HasMaxLength(EfConstants.Lenght.Medium); + + // supporting index on owned types: + // https://github.com/dotnet/efcore/issues/11336 + // https://github.com/dotnet/efcore/issues/12637 + a.HasIndex(p => p.Value).IsUnique(); + } + ); + + builder.OwnsOne( + x => x.PhoneNumber, + a => + { + a.Property(p => p.Value) + .HasColumnName(nameof(Customer.PhoneNumber).Underscore()) + .IsRequired() + .HasMaxLength(EfConstants.Lenght.Tiny); + + // supporting index on owned types: + // https://github.com/dotnet/efcore/issues/11336 + // https://github.com/dotnet/efcore/issues/12637 + a.HasIndex(p => p.Value).IsUnique(); + } + ); + + builder.OwnsOne(m => m.Name); + + builder.OwnsOne( + m => m.Address, + a => + { + a.Property(p => p.City).HasMaxLength(EfConstants.Lenght.Short); + a.Property(p => p.Country).HasMaxLength(EfConstants.Lenght.Medium); + a.Property(p => p.Detail).HasMaxLength(EfConstants.Lenght.Medium); + a.Property(p => p.PostalCode) + .HasConversion(s => s.Value, v => new PostalCode { Value = v }) + .IsRequired(false); + } + ); + + builder.OwnsOne( + x => x.Nationality, + a => + { + // configuration just for changing column name in db (instead of nationality_value) + a.Property(x => x.Value) + .HasColumnName(nameof(Customer.Nationality).Underscore()) + .HasMaxLength(EfConstants.Lenght.Short); + } + ); + + builder.OwnsOne( + x => x.BirthDate, + a => + { + // configuration just for changing column name in db (instead of birthDate_value) + a.Property(x => x.Value).HasColumnName(nameof(Customer.BirthDate).Underscore()); + } + ); + + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomersDataSeeder.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomersDataSeeder.cs new file mode 100644 index 00000000..a87a542f --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/CustomersDataSeeder.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Customers.Customers.Data; + +public class CustomersDataSeeder : IDataSeeder +{ + public Task SeedAllAsync() + { + return Task.CompletedTask; + } + + public int Order => 1; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/Repositories/Mongo/CustomerReadRepository.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/Repositories/Mongo/CustomerReadRepository.cs new file mode 100644 index 00000000..704ad5f7 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/Repositories/Mongo/CustomerReadRepository.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.Shared.Data; +using Sieve.Services; + +namespace FoodDelivery.Services.Customers.Customers.Data.Repositories.Mongo; + +public class CustomerReadRepository + : MongoRepositoryBase, + ICustomerReadRepository +{ + public CustomerReadRepository(CustomersReadDbContext context, ISieveProcessor sieveProcessor) + : base(context, sieveProcessor) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/UOW/Mongo/CustomersReadUnitOfWork.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/UOW/Mongo/CustomersReadUnitOfWork.cs new file mode 100644 index 00000000..0b77ce27 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Data/UOW/Mongo/CustomersReadUnitOfWork.cs @@ -0,0 +1,22 @@ +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.Shared.Data; + +namespace FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; + +public class CustomersReadUnitOfWork : MongoUnitOfWork, ICustomersReadUnitOfWork +{ + public CustomersReadUnitOfWork( + CustomersReadDbContext context, + IRestockSubscriptionReadRepository restockRepository, + ICustomerReadRepository customerRepository + ) + : base(context) + { + RestockSubscriptionsRepository = restockRepository; + CustomersRepository = customerRepository; + } + + public IRestockSubscriptionReadRepository RestockSubscriptionsRepository { get; } + public ICustomerReadRepository CustomersRepository { get; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/AddressDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/AddressDto.cs new file mode 100644 index 00000000..d8e7c0f8 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/AddressDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Customers.Dtos.v1; + +public record AddressDto(string Country, string City, string Detail, string ZipCode); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerDto.cs new file mode 100644 index 00000000..0c100ca3 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Customers.Dtos.v1; + +public class CustomerDto { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerReadDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerReadDto.cs new file mode 100644 index 00000000..6e46d29c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Dtos/v1/CustomerReadDto.cs @@ -0,0 +1,17 @@ +namespace FoodDelivery.Services.Customers.Customers.Dtos.v1; + +public record CustomerReadDto +{ + public Guid Id { get; set; } + public long CustomerId { get; set; } + public Guid IdentityId { get; set; } + public string Email { get; set; } = null!; + public string Name { get; set; } = null!; + public string? Country { get; set; } + public string? City { get; set; } + public string? DetailAddress { get; set; } + public string? Nationality { get; set; } + public DateTime? BirthDate { get; set; } + public string? PhoneNumber { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerAlreadyExistsException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerAlreadyExistsException.cs new file mode 100644 index 00000000..d3847cee --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerAlreadyExistsException.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Application; + +public class CustomerAlreadyExistsException : AppException +{ + public long? CustomerId { get; } + public Guid? IdentityId { get; } + + public CustomerAlreadyExistsException(string message) + : base(message, StatusCodes.Status409Conflict) { } + + public CustomerAlreadyExistsException(Guid identityId) + : base($"Customer with IdentityId: '{identityId}' already exists.", StatusCodes.Status409Conflict) + { + IdentityId = identityId; + } + + public CustomerAlreadyExistsException(long customerId) + : base($"Customer with ID: '{customerId}' already exists.", StatusCodes.Status409Conflict) + { + CustomerId = customerId; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerNotFoundException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerNotFoundException.cs new file mode 100644 index 00000000..c3a95a6a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Application/CustomerNotFoundException.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Application; + +public class CustomerNotFoundException : AppException +{ + public CustomerNotFoundException(string message) + : base(message, StatusCodes.Status404NotFound) { } + + public CustomerNotFoundException(long id) + : base($"Customer with id '{id}' not found.", StatusCodes.Status404NotFound) { } + + public CustomerNotFoundException(Guid id) + : base($"Customer with id '{id}' not found.", StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyCompletedException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyCompletedException.cs new file mode 100644 index 00000000..0e03f9db --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyCompletedException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +internal class CustomerAlreadyCompletedException : AppException +{ + public long CustomerId { get; } + + public CustomerAlreadyCompletedException(long customerId) + : base($"Customer with ID: '{customerId}' already completed.") + { + CustomerId = customerId; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyVerifiedException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyVerifiedException.cs new file mode 100644 index 00000000..ff0884a3 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerAlreadyVerifiedException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +internal class CustomerAlreadyVerifiedException : AppException +{ + public long CustomerId { get; } + + public CustomerAlreadyVerifiedException(long customerId) + : base($"Customer with InternalCommandId: '{customerId}' already verified.") + { + CustomerId = customerId; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerDomainException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerDomainException.cs new file mode 100644 index 00000000..2d10f009 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerDomainException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +public class CustomerDomainException : DomainException +{ + public CustomerDomainException(string message, int statusCode = StatusCodes.Status400BadRequest) + : base(message, statusCode) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerNotActiveException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerNotActiveException.cs new file mode 100644 index 00000000..322df5d7 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/CustomerNotActiveException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +internal class CustomerNotActiveException : AppException +{ + public long CustomerId { get; } + + public CustomerNotActiveException(long customerId) + : base($"Customer with ID: '{customerId}' is not active.") + { + CustomerId = customerId; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNameException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNameException.cs new file mode 100644 index 00000000..546932f0 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNameException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +public class InvalidNameException : BadRequestException +{ + public string Name { get; } + + public InvalidNameException(string name) + : base($"Name: '{name}' is invalid.") + { + Name = name; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNationalityException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNationalityException.cs new file mode 100644 index 00000000..3b310b9c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/InvalidNationalityException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +public class InvalidNationalityException : BadRequestException +{ + public string Nationality { get; } + + public InvalidNationalityException(string nationality) + : base($"Nationality: '{nationality}' is invalid.") + { + Nationality = nationality; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/UnsupportedNationalityException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/UnsupportedNationalityException.cs new file mode 100644 index 00000000..76e0cd59 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Exceptions/Domain/UnsupportedNationalityException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +public class UnsupportedNationalityException : BadRequestException +{ + public string Nationality { get; } + + public UnsupportedNationalityException(string nationality) + : base($"Nationality: '{nationality}' is unsupported.") + { + Nationality = nationality; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs new file mode 100644 index 00000000..67f3cb98 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Customers.Extensions; + +public static class GuardExtensions { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs new file mode 100644 index 00000000..b411fe45 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs @@ -0,0 +1,22 @@ +using FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Customers.Customers.Extensions; + +internal static class MassTransitExtensions +{ + internal static void AddCustomerPublishers(this IRabbitMqBusFactoryConfigurator cfg) + { + cfg.Message( + e => e.SetEntityName($"{nameof(CustomerCreatedV1).Underscore()}.input_exchange") + ); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomer.cs new file mode 100644 index 00000000..1abe6161 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomer.cs @@ -0,0 +1,91 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Domain.ValueObjects; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.IdsGenerator; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +internal record CreateCustomer(string Email) : ITxCreateCommand +{ + public long Id { get; } = SnowFlakIdGenerator.NewId(); + + /// + /// Create a new customer with inline validation. + /// + /// + /// + public static CreateCustomer Of(string? email) + { + return new CreateCustomerValidator().HandleValidation(new CreateCustomer(email!)); + } +} + +internal class CreateCustomerValidator : AbstractValidator +{ + public CreateCustomerValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + } +} + +internal class CreateCustomerHandler : ICommandHandler +{ + private readonly IIdentityApiClient _identityApiClient; + private readonly CustomersDbContext _customersDbContext; + private readonly ILogger _logger; + + public CreateCustomerHandler( + IIdentityApiClient identityApiClient, + CustomersDbContext customersDbContext, + ILogger logger + ) + { + _identityApiClient = identityApiClient; + _customersDbContext = customersDbContext; + _logger = logger; + } + + public async Task Handle(CreateCustomer command, CancellationToken cancellationToken) + { + _logger.LogInformation("Creating customer"); + + command.NotBeNull(); + + if (_customersDbContext.Customers.Any(x => x.Email.Value == command.Email)) + throw new CustomerAlreadyExistsException($"Customer with email '{command.Email}' already exists."); + + var identityUser = await _identityApiClient.GetUserByEmailAsync(command.Email, cancellationToken); + + var customer = Customer.Create( + CustomerId.Of(command.Id), + Email.Of(identityUser!.Email), + PhoneNumber.Of(identityUser.PhoneNumber), + CustomerName.Of(identityUser.FirstName, identityUser.LastName), + identityUser.Id + ); + + await _customersDbContext.AddAsync(customer, cancellationToken); + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Created a customer with ID: '{@CustomerId}'", customer.Id); + + return new CreateCustomerResult(customer.Id, customer.IdentityId); + } +} + +internal record CreateCustomerResult(long CustomerId, Guid IdentityUserId); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomerEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomerEndpoint.cs new file mode 100644 index 00000000..c851ab89 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/CreateCustomerEndpoint.cs @@ -0,0 +1,69 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; + +internal class CreateCustomerEndpoint + : ICommandMinimalEndpoint< + CreateCustomerRequest, + CreateCustomerRequestParameters, + CreatedAtRoute, + UnAuthorizedHttpProblemResult, + ValidationProblem + > +{ + public string GroupName => CustomersConfigs.Tag; + public string PrefixRoute => CustomersConfigs.CustomersPrefixUri; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapPost("/", HandleAsync) + .RequireAuthorization() + .WithName(nameof(CreateCustomer)) + .WithDisplayName(nameof(CreateCustomer).Humanize()) + .WithSummaryAndDescription(nameof(CreateCustomer).Humanize(), nameof(CreateCustomer).Humanize()); + // .Produces("Customer created successfully.", StatusCodes.Status201Created) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem("UnAuthorized request.", StatusCodes.Status401Unauthorized) + } + + public async Task< + Results, UnAuthorizedHttpProblemResult, ValidationProblem> + > HandleAsync(CreateCustomerRequestParameters requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = CreateCustomer.Of(request.Email); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.CreatedAtRoute( + new CreateCustomerResponse(result.CustomerId), + nameof(GettingCustomerById), + new { id = result.CustomerId } + ); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record CreateCustomerRequestParameters( + [FromBody] CreateCustomerRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +public record CreateCustomerRequest(string? Email); + +public record CreateCustomerResponse(long CustomerId); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs new file mode 100644 index 00000000..d49da331 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs @@ -0,0 +1,117 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +public record CustomerCreated( + long Id, + string FirstName, + string LastName, + string Email, + string PhoneNumber, + Guid IdentityId, + DateTime CreatedAt, + string? Address, + DateTime? BirthDate, + string? Nationality +) : DomainEvent +{ + public static CustomerCreated Of( + long id, + string? firstName, + string? lastName, + string? email, + string? phoneNumber, + Guid identityId, + DateTime createdAt, + string? address, + DateTime? birthDate, + string? nationality + ) + { + return new CustomerCreatedValidator().HandleValidation( + new CustomerCreated( + id, + firstName!, + lastName!, + email!, + phoneNumber!, + identityId, + createdAt, + address!, + birthDate, + nationality! + ) + ); + + // // Also if validation rules are simple we can just validate inputs explicitly + // id.NotBeEmpty(); + // firstName.NotBeNullOrWhiteSpace(); + // lastName.NotBeNullOrWhiteSpace(); + // return new CustomerCreated( + // id, + // firstName, + // lastName, + // email, + // phoneNumber, + // identityId, + // address, + // birthDate, + // nationality + // ); + } +} + +internal class CustomerCreatedValidator : AbstractValidator +{ + public CustomerCreatedValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.IdentityId).NotEmpty(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + } +} + +internal class CustomerCreatedHandler : IDomainEventHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly IMapper _mapper; + + public CustomerCreatedHandler(ICommandProcessor commandProcessor, IMapper mapper) + { + _commandProcessor = commandProcessor; + _mapper = mapper; + } + + public Task Handle(CustomerCreated notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + var mongoReadCommand = _mapper.Map(notification); + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + // Schedule multiple read sides to execute here + return _commandProcessor.ScheduleAsync(new IInternalCommand[] { mongoReadCommand }, cancellationToken); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs new file mode 100644 index 00000000..c1f5c0c6 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs @@ -0,0 +1,107 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; + +public record CreateCustomerRead( + long CustomerId, + Guid IdentityId, + string Email, + string FirstName, + string LastName, + string PhoneNumber, + DateTime Created, + DateTime? BirthDate = null, + string? Country = null, + string? City = null, + string? DetailAddress = null, + string? Nationality = null +) : InternalCommand +{ + public string FullName => $"{FirstName} {LastName}"; + + public static CreateCustomerRead Of( + long customerId, + Guid identityId, + string? email, + string? firstName, + string? lastName, + string? phoneNumber, + DateTime created, + DateTime? birthDate = null, + string? country = null, + string? city = null, + string? detailAddress = null, + string? nationality = null + ) + { + return new CreateCustomerReadValidator().HandleValidation( + new CreateCustomerRead( + customerId, + identityId, + email!, + firstName!, + lastName!, + phoneNumber!, + created, + birthDate, + country, + city, + detailAddress, + nationality + ) + ); + } +} + +internal class CreateCustomerReadValidator : AbstractValidator +{ + public CreateCustomerReadValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + RuleFor(x => x.CustomerId).NotEmpty(); + RuleFor(x => x.IdentityId).NotEmpty(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(x => x.BirthDate).NotEmpty(); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + } +} + +internal class CreateCustomerReadHandler : ICommandHandler +{ + private readonly IMapper _mapper; + private readonly ICustomersReadUnitOfWork _unitOfWork; + + // totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here + // https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ + public CreateCustomerReadHandler(IMapper mapper, ICustomersReadUnitOfWork unitOfWork) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task Handle(CreateCustomerRead command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var readModel = _mapper.Map(command); + + await _unitOfWork.CustomersRepository.AddAsync(readModel, cancellationToken: cancellationToken); + await _unitOfWork.CommitAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerId.cs new file mode 100644 index 00000000..a5eca59c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerId.cs @@ -0,0 +1,61 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomerByCustomerId.v1; + +internal record GetCustomerByCustomerId(long CustomerId) : IQuery +{ + public static GetCustomerByCustomerId Of(long customerId) + { + return new GetCustomerByCustomerIdValidator().HandleValidation(new GetCustomerByCustomerId(customerId)); + } +} + +internal class GetCustomerByCustomerIdValidator : AbstractValidator +{ + public GetCustomerByCustomerIdValidator() + { + RuleFor(x => x.CustomerId).NotEmpty(); + } +} + +// totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here +// https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ +internal class GetCustomerByCustomerIdHandler : IQueryHandler +{ + private readonly ICustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetCustomerByCustomerIdHandler(ICustomersReadUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetCustomerByCustomerId query, + CancellationToken cancellationToken + ) + { + query.NotBeNull(); + var customer = await _unitOfWork.CustomersRepository.FindOneAsync( + x => x.CustomerId == query.CustomerId, + cancellationToken: cancellationToken + ); + + if (customer == null) + throw new CustomerNotFoundException(query.CustomerId); + + var customerDto = _mapper.Map(customer); + + return new GetCustomerByCustomerIdResult(customerDto); + } +} + +internal record GetCustomerByCustomerIdResult(CustomerReadDto Customer); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdEndpoint.cs new file mode 100644 index 00000000..5565eddf --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdEndpoint.cs @@ -0,0 +1,70 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomerByCustomerId.v1; + +internal class GetCustomerByCustomerIdEndpointEndpoint + : IQueryMinimalEndpoint< + GetCustomerByCustomerIdRequestParameters, + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => CustomersConfigs.Tag; + public string PrefixRoute => CustomersConfigs.CustomersPrefixUri; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapGet("/{customerId}", HandleAsync) + // .RequireAuthorization() + .WithName(nameof(GetCustomerByCustomerId)) + .WithDisplayName(nameof(GetCustomerByCustomerId).Humanize()) + .WithSummaryAndDescription( + nameof(GetCustomerByCustomerId).Humanize(), + nameof(GetCustomerByCustomerId).Humanize() + ); + // .Produces("Customer fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + } + + public async Task< + Results< + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > + > HandleAsync([AsParameters] GetCustomerByCustomerIdRequestParameters requestParameters) + { + var (id, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetCustomerByCustomerId.Of(id), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetCustomerByCustomerIdResponse(result.Customer)); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetCustomerByCustomerIdRequestParameters( + [FromRoute] long CustomerId, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +internal record GetCustomerByCustomerIdResponse(CustomerReadDto Customer); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerById.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerById.cs new file mode 100644 index 00000000..4507be0c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerById.cs @@ -0,0 +1,59 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomerById.v1; + +internal record GetCustomerById(Guid Id) : IQuery +{ + public static GetCustomerById Of(Guid id) + { + return new GetCustomerByIdValidator().HandleValidation(new GetCustomerById(id)); + } +} + +internal class GetCustomerByIdValidator : AbstractValidator +{ + public GetCustomerByIdValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +// totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here +// https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ +internal class GetCustomerByIdHandler : IQueryHandler +{ + private readonly ICustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetCustomerByIdHandler(ICustomersReadUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle(GetCustomerById query, CancellationToken cancellationToken) + { + query.NotBeNull(); + + var customer = await _unitOfWork.CustomersRepository.FindOneAsync( + x => x.Id == query.Id, + cancellationToken: cancellationToken + ); + + if (customer is null) + throw new CustomerNotFoundException(query.Id); + + var customerDto = _mapper.Map(customer); + + return new GetCustomerByIdResult(customerDto); + } +} + +internal record GetCustomerByIdResult(CustomerReadDto Customer); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerByIdEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerByIdEndpoint.cs new file mode 100644 index 00000000..aecfbea0 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomerById/v1/GetCustomerByIdEndpoint.cs @@ -0,0 +1,68 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomerById.v1; + +internal class GetCustomerByIdEndpointEndpoint + : IQueryMinimalEndpoint< + GetCustomerByIdRequestParameters, + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => CustomersConfigs.Tag; + public string PrefixRoute => CustomersConfigs.CustomersPrefixUri; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapGet("/{id:guid}", HandleAsync) + .RequireAuthorization() + .RequireAuthorization() + .WithName(nameof(GetCustomerById)) + .WithDisplayName(nameof(GetCustomerById).Humanize()) + .WithSummaryAndDescription(nameof(GetCustomerById).Humanize(), nameof(GetCustomerById).Humanize()); + // .Produces("Customer fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + } + + public async Task< + Results< + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > + > HandleAsync([AsParameters] GetCustomerByIdRequestParameters requestParameters) + { + var (id, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetCustomerById.Of(id), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetCustomerByIdResponse(result.Customer)); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetCustomerByIdRequestParameters( + [FromRoute] Guid Id, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +public record GetCustomerByIdResponse(CustomerReadDto Customer); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomers.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomers.cs new file mode 100644 index 00000000..2c7cb154 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomers.cs @@ -0,0 +1,77 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Sieve.Services; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomers.v1; + +internal record GetCustomers : PageQuery +{ + public static GetCustomers Of(PageRequest pageRequest) + { + var (pageNumber, pageSize, filters, sortOrder) = pageRequest; + + return new GetCustomersValidator().HandleValidation( + new GetCustomers + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + } + ); + } +} + +internal class GetCustomersValidator : AbstractValidator +{ + public GetCustomersValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage("Page should at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage("PageSize should at least greater than or equal to 1."); + } +} + +internal class GetCustomersHandler : IQueryHandler +{ + private readonly ICustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly ISieveProcessor _sieveProcessor; + + public GetCustomersHandler(ICustomersReadUnitOfWork unitOfWork, IMapper mapper, ISieveProcessor sieveProcessor) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _sieveProcessor = sieveProcessor; + } + + public async Task Handle(GetCustomers request, CancellationToken cancellationToken) + { + var customer = await _unitOfWork.CustomersRepository.GetByPageFilter( + request, + _mapper.ConfigurationProvider, + sortExpression: x => x.City!, + cancellationToken: cancellationToken + ); + + return new GetCustomersResult(customer); + } +} + +internal record GetCustomersResult(IPageList Customers); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomersEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomersEndpoint.cs new file mode 100644 index 00000000..370a2eda --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/GettingCustomers/v1/GetCustomersEndpoint.cs @@ -0,0 +1,79 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.Customers.Features.GettingCustomers.v1; + +internal class GetCustomersEndpoint + : IQueryMinimalEndpoint< + GetCustomersRequestParameters, + Ok, + ValidationProblem, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => CustomersConfigs.Tag; + public string PrefixRoute => CustomersConfigs.CustomersPrefixUri; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + // return app.MapQueryEndpoint("/") + return builder + .MapGet("/", HandleAsync) + .RequireAuthorization() + .WithName(nameof(GetCustomers)) + .WithSummaryAndDescription(nameof(GetCustomers).Humanize(), nameof(GetCustomers).Humanize()) + .WithDisplayName(nameof(GetCustomers).Humanize()); + // .Produces("Customers fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + } + + public async Task, ValidationProblem, UnAuthorizedHttpProblemResult>> HandleAsync( + [AsParameters] GetCustomersRequestParameters requestParameters + ) + { + var (context, queryProcessor, mapper, cancellationToken, pageSize, pageNumber, filters, sortOrder) = + requestParameters; + + var query = GetCustomers.Of( + new PageRequest + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + } + ); + + var result = await queryProcessor.SendAsync(query, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetCustomersResponse(result.Customers)); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetCustomersRequestParameters( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken, + int PageSize = 10, + int PageNumber = 1, + string? Filters = null, + string? SortOrder = null +) : IHttpQuery, IPageRequest; + +internal record GetCustomersResponse(IPageList Customers); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Events/Domain/CustomerUpdated.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Events/Domain/CustomerUpdated.cs new file mode 100644 index 00000000..7f33966c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Events/Domain/CustomerUpdated.cs @@ -0,0 +1,107 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng + +public record CustomerUpdated( + long Id, + string FirstName, + string LastName, + string Email, + string PhoneNumber, + Guid IdentityId, + DateTime CreatedAt, + DateTime? BirthDate = null, + string? Country = null, + string? City = null, + string? DetailAddress = null, + string? Nationality = null +) : DomainEvent +{ + public static CustomerUpdated Of( + long id, + string? firstName, + string? lastName, + string? email, + string? phoneNumber, + Guid identityId, + DateTime createdAt, + DateTime? birthDate, + string? country = null, + string? city = null, + string? detailAddress = null, + string? nationality = null + ) + { + return new CustomerUpdatedValidator().HandleValidation( + new CustomerUpdated( + id, + firstName!, + lastName!, + email!, + phoneNumber!, + identityId, + createdAt, + birthDate, + country, + city, + detailAddress, + nationality + ) + ); + } +} + +internal class CustomerUpdatedValidator : AbstractValidator +{ + public CustomerUpdatedValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.IdentityId).NotEmpty(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + } +} + +internal class CustomerCreatedHandler : IDomainEventHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly IMapper _mapper; + + public CustomerCreatedHandler(ICommandProcessor commandProcessor, IMapper mapper) + { + _commandProcessor = commandProcessor; + _mapper = mapper; + } + + public Task Handle(CustomerUpdated notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + var mongoReadCommand = _mapper.Map(notification); + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + // Schedule multiple read sides to execute here + return _commandProcessor.ScheduleAsync(new IInternalCommand[] { mongoReadCommand }, cancellationToken); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Read/Mongo/UpdateCustomerRead.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Read/Mongo/UpdateCustomerRead.cs new file mode 100644 index 00000000..cd785325 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/Read/Mongo/UpdateCustomerRead.cs @@ -0,0 +1,116 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; + +internal record UpdateCustomerRead( + Guid Id, + long CustomerId, + Guid IdentityId, + string Email, + string FirstName, + string LastName, + string PhoneNumber, + DateTime? BirthDate = null, + string? Country = null, + string? City = null, + string? DetailAddress = null, + string? Nationality = null +) : InternalCommand +{ + public string FullName => $"{FirstName} {LastName}"; + + public static UpdateCustomerRead Of( + Guid id, + long customerId, + Guid identityId, + string? email, + string? firstName, + string? lastName, + string? phoneNumber, + DateTime? birthDate = null, + string? country = null, + string? city = null, + string? detailAddress = null, + string? nationality = null + ) + { + return new UpdateCustomerReadValidator().HandleValidation( + new UpdateCustomerRead( + id, + customerId, + identityId, + email!, + firstName!, + lastName!, + phoneNumber!, + birthDate, + country, + city, + detailAddress, + nationality + ) + ); + } +} + +internal class UpdateCustomerReadValidator : AbstractValidator +{ + public UpdateCustomerReadValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + } +} + +internal class UpdateCustomerReadHandler : ICommandHandler +{ + private readonly ICustomersReadUnitOfWork _customersReadUnitOfWork; + private readonly IMapper _mapper; + + // totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here + // https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ + public UpdateCustomerReadHandler(ICustomersReadUnitOfWork customersReadUnitOfWork, IMapper mapper) + { + _customersReadUnitOfWork = customersReadUnitOfWork; + _mapper = mapper; + } + + public async Task Handle(UpdateCustomerRead command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var existingCustomer = await _customersReadUnitOfWork.CustomersRepository.FindOneAsync( + x => x.CustomerId == command.CustomerId, + cancellationToken + ); + + if (existingCustomer is null) + { + throw new CustomerNotFoundException(command.CustomerId); + } + + var updateCustomer = _mapper.Map(command, existingCustomer); + + await _customersReadUnitOfWork.CustomersRepository.UpdateAsync(updateCustomer, cancellationToken); + + await _customersReadUnitOfWork.CommitAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomer.cs new file mode 100644 index 00000000..58ac03c8 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomer.cs @@ -0,0 +1,120 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Domain.ValueObjects; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng + +public sealed record UpdateCustomer( + long Id, + string FirstName, + string LastName, + string Email, + string PhoneNumber, + DateTime? BirthDate = null, + string? DetailAddress = null, + string? Nationality = null +) : ICommand +{ + /// + /// Update the customer with inline validation. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static UpdateCustomer Of( + long id, + string? firstName, + string? lastName, + string? email, + string? phoneNumber, + DateTime? birthDate = null, + string? detailAddress = null, + string? nationality = null + ) + { + return new UpdateCustomerValidator().HandleValidation( + new UpdateCustomer(id, firstName!, lastName!, email!, phoneNumber!, birthDate, detailAddress, nationality) + ); + } +} + +internal class UpdateCustomerValidator : AbstractValidator +{ + public UpdateCustomerValidator() + { + RuleFor(x => x.Email).NotNull().NotEmpty().EmailAddress().WithMessage("Email address is invalid."); + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + } +} + +internal class UpdateCustomerHandler : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly ILogger _logger; + + public UpdateCustomerHandler(CustomersDbContext customersDbContext, ILogger logger) + { + _customersDbContext = customersDbContext; + _logger = logger; + } + + public async Task Handle(UpdateCustomer command, CancellationToken cancellationToken) + { + _logger.LogInformation("Updating customer"); + + command.NotBeNull(); + + var customer = await _customersDbContext.Customers.FindAsync( + new object?[] { CustomerId.Of(command.Id) }, + cancellationToken: cancellationToken + ); + + if (customer is null) + { + throw new CustomerNotFoundException(command.Id); + } + + customer.Update( + Email.Of(command.Email), + PhoneNumber.Of(command.PhoneNumber), + CustomerName.Of(command.FirstName, command.LastName), + null, + command.BirthDate == null ? null : BirthDate.Of((DateTime)command.BirthDate), + command.Nationality == null ? null : Nationality.Of(command.Nationality) + ); + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Customer with Id: '{@CustomerId}' updated", customer.Id); + + // TODO: Update Identity user with new customer changes + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomerEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomerEndpoint.cs new file mode 100644 index 00000000..65d8da7e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/UpdatingCustomer/v1/UpdateCustomerEndpoint.cs @@ -0,0 +1,64 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using Humanizer; + +namespace FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; + +internal class UpdateCustomerEndpoint : ICommandMinimalEndpoint +{ + public string GroupName => CustomersConfigs.Tag; + public string PrefixRoute => CustomersConfigs.CustomersPrefixUri; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapPut("/{id}", HandleAsync) + .RequireAuthorization() + .RequireAuthorization() + .WithName(nameof(UpdateCustomer)) + .WithDisplayName(nameof(UpdateCustomer).Humanize()) + .WithSummaryAndDescription(nameof(UpdateCustomer).Humanize(), nameof(UpdateCustomer).Humanize()); + // .Produces("Customer updated successfully.", StatusCodes.Status204NoContent) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem("UnAuthorized request.", StatusCodes.Status401Unauthorized) + } + + public async Task HandleAsync(UpdateCustomerRequestParameters requestParameters) + { + var (request, id, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = mapper.Map(request); + command = command with { Id = id }; + + await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.NoContent(); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record UpdateCustomerRequestParameters( + [FromBody] UpdateCustomerRequest Request, + [FromRoute] long Id, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +// These parameters can be pass null from the user +internal sealed record UpdateCustomerRequest( + string? FirstName, + string? LastName, + string? Email, + string? PhoneNumber, + DateTime? BirthDate = null, + string? Nationality = null, + string? Address = null +); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs new file mode 100644 index 00000000..b49c0713 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs @@ -0,0 +1,155 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.ValueObjects; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.ValueObjects; + +namespace FoodDelivery.Services.Customers.Customers.Models; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://github.com/dotnet/efcore/issues/29940 +public class Customer : Aggregate +{ + // EF + // this constructor is needed when we have a parameter constructor that has some navigation property classes in the parameters and ef will skip it and try to find other constructor, here default constructor (maybe will fix .net 8) + private Customer() { } + + public Guid IdentityId { get; private set; } + public Email Email { get; private set; } = default!; + public CustomerName Name { get; private set; } = default!; + public Address? Address { get; private set; } + public Nationality? Nationality { get; private set; } + public BirthDate? BirthDate { get; private set; } + public PhoneNumber PhoneNumber { get; private set; } = default!; + + public static Customer Create( + CustomerId id, + Email email, + PhoneNumber phoneNumber, + CustomerName name, + Guid identityId, + Address? address = null, + BirthDate? birthDate = null, + Nationality? nationality = null + ) + { + // input validation will do in the `command` and our `value objects` before arriving to entity and makes or domain cleaner, here we just do business validation + var customer = new Customer + { + Id = id, + Email = email, + PhoneNumber = phoneNumber, + Name = name, + IdentityId = identityId, + BirthDate = birthDate, + Address = address, + Nationality = nationality + }; + + var (firstName, lastName) = name; + + customer.AddDomainEvents( + CustomerCreated.Of( + id, + firstName, + lastName, + email, + phoneNumber, + identityId, + DateTime.Now, + address?.Detail, + birthDate!, + nationality! + ) + ); + + return customer; + } + + public void Update( + Email email, + PhoneNumber phoneNumber, + CustomerName name, + Address? address = null, + BirthDate? birthDate = null, + Nationality? nationality = null + ) + { + Email = email; + PhoneNumber = phoneNumber; + Name = name; + + if (address is { }) + { + Address = address; + } + + if (birthDate is { }) + { + BirthDate = birthDate; + } + + if (nationality is { }) + { + Nationality = nationality; + } + + var (firstName, lastName) = name; + + AddDomainEvents( + CustomerUpdated.Of( + Id, + firstName, + lastName, + email, + phoneNumber, + IdentityId, + DateTime.Now, + birthDate!, + address?.Country, + address?.City, + address?.Detail, + nationality! + ) + ); + } + + public void Deconstruct( + out long id, + out Guid identityId, + out string email, + out string firstName, + out string lastName, + out string phoneNumber, + out string? country, + out string? city, + out string? detailedAddress, + out string? nationality, + out DateTime? birthDate + ) => + ( + id, + identityId, + email, + firstName, + lastName, + phoneNumber, + country, + city, + detailedAddress, + nationality, + birthDate + ) = ( + Id, + IdentityId, + Email, + Name.FirstName, + Name.LastName, + PhoneNumber, + Address?.Country, + Address?.City, + Address?.Detail, + Nationality!, + BirthDate! + ); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Reads/Customer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Reads/Customer.cs new file mode 100644 index 00000000..e2bd8abf --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Reads/Customer.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Abstractions.Domain; + +namespace FoodDelivery.Services.Customers.Customers.Models.Reads; + +public record Customer : IHaveIdentity +{ + public Guid Id { get; init; } + public required long CustomerId { get; init; } + public required Guid IdentityId { get; init; } + public required string Email { get; init; } = null!; + public required string FirstName { get; init; } = null!; + public required string LastName { get; init; } = null!; + public required string FullName { get; init; } = null!; + public required string PhoneNumber { get; init; } + public string? Country { get; init; } + public string? City { get; init; } + public string? DetailAddress { get; init; } + public string? Nationality { get; init; } + public DateTime? BirthDate { get; init; } + public required DateTime Created { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/UserState.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/UserState.cs new file mode 100644 index 00000000..f7a3f78f --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/UserState.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Customers.Customers.Models; + +public enum UserState +{ + Active = 1, + Locked = 2 +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs new file mode 100644 index 00000000..acb05294 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs @@ -0,0 +1,17 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Customers.Customers.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public record CustomerId : AggregateId +{ + // EF + private CustomerId(long value) + : base(value) { } + + // validations should be placed here instead of constructor + public static CustomerId Of(long id) => new(id.NotBeNegativeOrZero()); + + public static implicit operator long(CustomerId id) => id.Value; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerName.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerName.cs new file mode 100644 index 00000000..9061bba0 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerName.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +namespace FoodDelivery.Services.Customers.Customers.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public class CustomerName : ValueObject +{ + // EF + private CustomerName() { } + + public string FirstName { get; private set; } = default!; + public string LastName { get; private set; } = default!; + public string FullName => FirstName + " " + LastName; + + public static CustomerName Of([NotNull] string? firstName, [NotNull] string? lastName) + { + firstName.NotBeNullOrWhiteSpace(); + lastName.NotBeNullOrWhiteSpace(); + + if (firstName.Length is > 100 or < 3) + { + throw new InvalidNameException(firstName); + } + + if (lastName.Length is > 100 or < 3) + { + throw new InvalidNameException(lastName); + } + + return new CustomerName { FirstName = firstName, LastName = lastName }; + } + + public void Deconstruct(out string firstName, out string lastName) => (firstName, lastName) = (FirstName, LastName); + + // will call for equality(==) checks. + protected override IEnumerable GetEqualityComponents() + { + yield return FirstName; + yield return LastName; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs new file mode 100644 index 00000000..9227d700 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; +using FluentValidation; +using FoodDelivery.Services.Customers.Customers.Exceptions.Domain; + +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local +namespace FoodDelivery.Services.Customers.Customers.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public record Nationality +{ + private static readonly HashSet _allowedNationality = new() { "IR", "DE", "FR", "ES", "GB", "US" }; + + // EF + public Nationality(string value) + { + Value = value; + } + + // Note: in entities with none default constructor, for setting constructor parameter, we need a private set property + // when we didn't define this property in fluent configuration map, because for getting mapping list of properties to set + // in the constructor it should not be read only without set (for bypassing calculate fields)- https://learn.microsoft.com/en-us/ef/core/modeling/constructors#read-only-properties + public string Value { get; private set; } = default!; + + public static Nationality Of([NotNull] string? value) + { + value.NotBeNullOrWhiteSpace(); + + if (value.Length != 2) + { + throw new InvalidNationalityException(value); + } + + value = value.ToUpperInvariant(); + if (!_allowedNationality.Contains(value)) + { + throw new UnsupportedNationalityException(value); + } + + return new Nationality(value); + } + + public static implicit operator string(Nationality value) => value.Value; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/CustomersConstants.cs b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersConstants.cs new file mode 100644 index 00000000..24004ac1 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersConstants.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Customers; + +public class CustomersConstants +{ + public static class Role + { + public const string Admin = "admin"; + public const string User = "user"; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs new file mode 100644 index 00000000..93f189f1 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers; + +public class CustomersMetadata { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj b/src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj new file mode 100644 index 00000000..bbfc41ac --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj @@ -0,0 +1,46 @@ + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Exceptions/ProductNotFoundException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Exceptions/ProductNotFoundException.cs new file mode 100644 index 00000000..7b86153b --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Exceptions/ProductNotFoundException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.Products.Exceptions; + +public class ProductNotFoundException : AppException +{ + public ProductNotFoundException(long id) + : base($"Product with id {id} not found", StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs new file mode 100644 index 00000000..1488d699 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs @@ -0,0 +1,12 @@ +using FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; +using MassTransit; + +namespace FoodDelivery.Services.Customers.Products.Features.CreatingProduct.v1.Events.Integration.External; + +public class ProductCreatedConsumer : IConsumer +{ + public Task Consume(ConsumeContext context) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs new file mode 100644 index 00000000..744d979a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs @@ -0,0 +1,37 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; +using FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; +using MassTransit; + +namespace FoodDelivery.Services.Customers.Products.Features.ReplenishingProductStock.v1.Events.Integration.External; + +public class ProductStockReplenishedConsumer : IConsumer +{ + private readonly ICommandProcessor _commandProcessor; + private readonly ILogger _logger; + + public ProductStockReplenishedConsumer( + ICommandProcessor commandProcessor, + ILogger logger + ) + { + _commandProcessor = commandProcessor; + _logger = logger; + } + + // If this handler is called successfully, it will send a ACK to rabbitmq for removing message from the queue and if we have an exception it send an NACK to rabbitmq + // and with NACK we can retry the message with re-queueing this message to the broker + public async Task Consume(ConsumeContext context) + { + var productStockReplenished = context.Message; + + await _commandProcessor.SendAsync( + ProcessRestockNotification.Of(productStockReplenished.ProductId, productStockReplenished.NewStock) + ); + + _logger.LogInformation( + "Sending restock notification command for product {ProductId}", + productStockReplenished.ProductId + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs new file mode 100644 index 00000000..c40db874 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs @@ -0,0 +1,68 @@ +using FoodDelivery.Services.Customers.Products.Features.CreatingProduct.v1.Events.Integration.External; +using FoodDelivery.Services.Customers.Products.Features.ReplenishingProductStock.v1.Events.Integration.External; +using FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Customers.Products; + +internal static class MassTransitExtensions +{ + internal static void AddProductEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) + { + cfg.ReceiveEndpoint( + nameof(ProductStockReplenishedV1).Underscore(), + re => + { + // turns off default fanout settings + re.ConfigureConsumeTopology = true; + + // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + re.SetQuorumQueue(); + + re.Bind( + $"{nameof(ProductStockReplenishedV1).Underscore()}.input_exchange", + e => + { + e.RoutingKey = nameof(ProductStockReplenishedV1).Underscore(); + e.ExchangeType = ExchangeType.Direct; + } + ); + + // https://github.com/MassTransit/MassTransit/discussions/3117 + // https://masstransit-project.com/usage/configuration.html#receive-endpoints + re.ConfigureConsumer(context); + + re.RethrowFaultedMessages(); + } + ); + + cfg.ReceiveEndpoint( + nameof(ProductCreatedV1).Underscore(), + re => + { + // turns off default fanout settings + re.ConfigureConsumeTopology = true; + + // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + re.SetQuorumQueue(); + + re.Bind( + $"{nameof(ProductCreatedV1).Underscore()}.input_exchange", + e => + { + e.RoutingKey = nameof(ProductCreatedV1).Underscore(); + e.ExchangeType = ExchangeType.Direct; + } + ); + + // https://github.com/MassTransit/MassTransit/discussions/3117 + // https://masstransit-project.com/usage/configuration.html#receive-endpoints + re.ConfigureConsumer(context); + + re.RethrowFaultedMessages(); + } + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Models/Product.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Models/Product.cs new file mode 100644 index 00000000..9469eec9 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Models/Product.cs @@ -0,0 +1,24 @@ +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; + +namespace FoodDelivery.Services.Customers.Products.Models; + +public record Product +{ + public long Id { get; init; } + public string Name { get; init; } = default!; + public string? Description { get; init; } + public decimal Price { get; init; } + public long CategoryId { get; init; } + public string CategoryName { get; init; } = default!; + public long SupplierId { get; init; } + public string SupplierName { get; init; } = default!; + public long BrandId { get; init; } + public string BrandName { get; init; } = default!; + public int AvailableStock { get; init; } + public int RestockThreshold { get; init; } + public int MaxStockThreshold { get; init; } + public ProductStatus ProductStatus { get; init; } + public int Height { get; init; } + public int Width { get; init; } + public int Depth { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/ProductId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/ProductId.cs new file mode 100644 index 00000000..4b075b7e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/ProductId.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Customers.Products; + +public record ProductId : AggregateId +{ + // EF + protected ProductId(long value) + : base(value) { } + + public static implicit operator long(ProductId id) => id.Value; + + // validations should be placed here instead of constructor + public static ProductId Of(long id) => new(id.NotBeNegativeOrZero()); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/Repositories/Mongo/RestockSubscriptionReadRepository.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/Repositories/Mongo/RestockSubscriptionReadRepository.cs new file mode 100644 index 00000000..03a7eb2b --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/Repositories/Mongo/RestockSubscriptionReadRepository.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.Shared.Data; +using Sieve.Services; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Data.Repositories.Mongo; + +public class RestockSubscriptionReadRepository + : MongoRepositoryBase, + IRestockSubscriptionReadRepository +{ + public RestockSubscriptionReadRepository(CustomersReadDbContext context, ISieveProcessor sieveProcessor) + : base(context, sieveProcessor) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/RestockSubscriptionEntityConfiguration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/RestockSubscriptionEntityConfiguration.cs new file mode 100644 index 00000000..e438f770 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Data/RestockSubscriptionEntityConfiguration.cs @@ -0,0 +1,41 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Data; + +public class RestockSubscriptionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(RestockSubscription).Pluralize().Underscore(), CustomersDbContext.DefaultSchema); + + // ids will use strongly typed-id value converter selector globally + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.Property(x => x.Processed).HasDefaultValue(false); + + builder.Property(c => c.CustomerId); + + builder.HasOne().WithMany().HasForeignKey(x => x.CustomerId); + + builder.OwnsOne(x => x.ProductInformation); + + builder.OwnsOne( + x => x.Email, + a => + { + // configuration just for changing column name in db (instead of email_value) + a.Property(p => p.Value).HasColumnName(nameof(RestockSubscription.Email).Underscore()); + } + ); + + builder.Property(x => x.Created).HasDefaultValueSql(EfConstants.DateAlgorithm); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionDto.cs new file mode 100644 index 00000000..1fe94da4 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionDto.cs @@ -0,0 +1,13 @@ +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; + +public record RestockSubscriptionDto +{ + public long Id { get; init; } + public long CustomerId { get; init; } + public string Email { get; init; } = default!; + public long ProductId { get; init; } + public string ProductName { get; init; } = default!; + public DateTime Created { get; init; } + public bool Processed { get; init; } + public DateTime? ProcessedTime { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionReadDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionReadDto.cs new file mode 100644 index 00000000..67590ebc --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Dtos/v1/RestockSubscriptionReadDto.cs @@ -0,0 +1,11 @@ +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; + +public record RestockSubscriptionReadDto +{ + public string CustomerName { get; init; } = default!; + public string CustomerId { get; init; } = default!; + public string ProductId { get; init; } = default!; + public string ProductName { get; init; } = default!; + public bool Processed { get; init; } + public DateTime? ProcessedTime { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Application/RestockSubscriptionNotFoundException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Application/RestockSubscriptionNotFoundException.cs new file mode 100644 index 00000000..f82d43fe --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Application/RestockSubscriptionNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Application; + +public class RestockSubscriptionNotFoundException : AppException +{ + public RestockSubscriptionNotFoundException(long id) + : base($"RestockSubscription with id: {id}not found", StatusCodes.Status404NotFound) { } + + public RestockSubscriptionNotFoundException(Guid id) + : base($"RestockSubscription with id: {id}not found", StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Domain/RestockSubscriptionDomainException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Domain/RestockSubscriptionDomainException.cs new file mode 100644 index 00000000..e49345e4 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Exceptions/Domain/RestockSubscriptionDomainException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Domain.Exceptions; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Domain; + +public class RestockSubscriptionDomainException : DomainException +{ + public RestockSubscriptionDomainException(string message) + : base(message) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateMongoRestockSubscriptionReadModels.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateMongoRestockSubscriptionReadModels.cs new file mode 100644 index 00000000..3b095f22 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateMongoRestockSubscriptionReadModels.cs @@ -0,0 +1,50 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; + +public record CreateMongoRestockSubscriptionReadModels( + long RestockSubscriptionId, + long CustomerId, + string CustomerName, + long ProductId, + string ProductName, + string Email, + DateTime Created, + bool Processed, + DateTime? ProcessedTime = null +) : InternalCommand +{ + public bool IsDeleted { get; init; } +} + +internal class CreateRestockSubscriptionReadModelHandler : ICommandHandler +{ + private readonly CustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public CreateRestockSubscriptionReadModelHandler(CustomersReadUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + CreateMongoRestockSubscriptionReadModels command, + CancellationToken cancellationToken + ) + { + command.NotBeNull(); + + var readModel = _mapper.Map(command); + + await _unitOfWork.RestockSubscriptionsRepository.AddAsync(readModel, cancellationToken: cancellationToken); + await _unitOfWork.CommitAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscription.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscription.cs new file mode 100644 index 00000000..b9dc997e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscription.cs @@ -0,0 +1,125 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Domain.ValueObjects; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.IdsGenerator; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Products; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Exceptions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; + +public record CreateRestockSubscription(long CustomerId, long ProductId, string Email) + : ITxCreateCommand +{ + /// + /// Create a new RestockSubscription with inline validation. + /// + /// + /// + /// + /// + public static CreateRestockSubscription Of(long customerId, long productId, string? email) + { + return new CreateRestockSubscriptionValidator().HandleValidation( + new CreateRestockSubscription(customerId, productId, email!) + ); + } + + public long Id { get; } = SnowFlakIdGenerator.NewId(); +} + +internal class CreateRestockSubscriptionValidator : AbstractValidator +{ + public CreateRestockSubscriptionValidator() + { + RuleFor(x => x.CustomerId).NotEmpty(); + + RuleFor(x => x.ProductId).NotEmpty(); + + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} + +internal class CreateRestockSubscriptionHandler + : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly ICatalogApiClient _catalogApiClient; + private readonly IMapper _mapper; + private readonly ILogger _logger; + + public CreateRestockSubscriptionHandler( + CustomersDbContext customersDbContext, + ICatalogApiClient catalogApiClient, + IMapper mapper, + ILogger logger + ) + { + _customersDbContext = customersDbContext; + _catalogApiClient = catalogApiClient; + _mapper = mapper; + _logger = logger; + } + + public async Task Handle( + CreateRestockSubscription request, + CancellationToken cancellationToken + ) + { + request.NotBeNull(); + + var existsCustomer = await _customersDbContext.Customers.AnyAsync( + x => x.Id == CustomerId.Of(request.CustomerId), + cancellationToken: cancellationToken + ); + + if (!existsCustomer) + { + throw new CustomerNotFoundException(request.CustomerId); + } + + var product = await _catalogApiClient.GetProductByIdAsync(request.ProductId, cancellationToken); + + if (product!.AvailableStock > 0) + throw new ProductHasStockException(product.Id, product.AvailableStock, product.Name); + + var alreadySubscribed = _customersDbContext.RestockSubscriptions.Any( + x => x.Email.Value == request.Email && x.ProductInformation.Id == request.ProductId && x.Processed == false + ); + + if (alreadySubscribed) + throw new ProductAlreadySubscribedException(product.Id, product.Name); + + var restockSubscription = RestockSubscription.Create( + RestockSubscriptionId.Of(request.Id), + CustomerId.Of(request.CustomerId), + ProductInformation.Of(ProductId.Of(product.Id), product.Name), + Email.Of(request.Email) + ); + + await _customersDbContext.AddAsync(restockSubscription, cancellationToken); + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "RestockSubscription with id '{@InternalCommandId}' saved successfully", + restockSubscription.Id + ); + + var restockSubscriptionDto = _mapper.Map(restockSubscription); + + return new CreateRestockSubscriptionResult(restockSubscriptionDto.Id); + } +} + +public record CreateRestockSubscriptionResult(long RestockSubscriptionId); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionEndpoint.cs new file mode 100644 index 00000000..312b90d1 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionEndpoint.cs @@ -0,0 +1,71 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomers.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; + +internal class CreateRestockSubscriptionEndpoint + : ICommandMinimalEndpoint< + CreateRestockSubscriptionRequest, + CreateRestockSubscriptionRequestParameters, + CreatedAtRoute, + UnAuthorizedHttpProblemResult, + ValidationProblem + > +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapPost("/", HandleAsync) + .AllowAnonymous() + // .Produces(StatusCodes.Status201Created) + // .ProducesValidationProblem() + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .WithName(nameof(CreateRestockSubscription)) + .WithSummaryAndDescription( + nameof(CreateRestockSubscription).Humanize(), + nameof(CreateRestockSubscription).Humanize() + ) + .WithDisplayName(nameof(GetCustomers).Humanize()); + } + + public async Task< + Results, UnAuthorizedHttpProblemResult, ValidationProblem> + > HandleAsync([AsParameters] CreateRestockSubscriptionRequestParameters requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = CreateRestockSubscription.Of(request.CustomerId, request.ProductId, request.Email); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.CreatedAtRoute( + new CreateRestockSubscriptionResponse(result.RestockSubscriptionId), + nameof(GetRestockSubscriptionById), + new { id = result.RestockSubscriptionId } + ); + } +} + +internal record CreateRestockSubscriptionRequestParameters( + [FromBody] CreateRestockSubscriptionRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +public record CreateRestockSubscriptionResponse(long RestockSubscriptionId); + +public record CreateRestockSubscriptionRequest(long CustomerId, long ProductId, string Email); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs new file mode 100644 index 00000000..109ed095 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs @@ -0,0 +1,99 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.Shared.Extensions; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types +public record RestockSubscriptionCreated( + long Id, + long ProductId, + long CustomerId, + string ProductName, + string Email, + DateTime Created, + bool Processed +) : DomainEvent +{ + public static RestockSubscriptionCreated Of( + long id, + long productId, + long customerId, + string productName, + string email, + DateTime created, + bool processed + ) + { + id.NotBeNegativeOrZero(); + productId.NotBeNegativeOrZero(); + customerId.NotBeNegativeOrZero(); + productName.NotBeNullOrWhiteSpace(); + email.NotBeNullOrWhiteSpace(); + created.NotBeEmpty(); + + return new RestockSubscriptionCreated(id, productId, customerId, productName, email, created, processed); + } + + public CreateMongoRestockSubscriptionReadModels ToCreateMongoRestockSubscriptionReadModels( + long customerId, + string customerName + ) + { + return new CreateMongoRestockSubscriptionReadModels( + Id, + customerId, + customerName, + ProductId, + ProductName, + Email, + Created, + Processed + ); + } +} + +internal class RestockSubscriptionCreatedHandler : IDomainEventHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly CustomersDbContext _customersDbContext; + + public RestockSubscriptionCreatedHandler(ICommandProcessor commandProcessor, CustomersDbContext customersDbContext) + { + _commandProcessor = commandProcessor; + _customersDbContext = customersDbContext; + } + + public async Task Handle(RestockSubscriptionCreated notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + + var customer = await _customersDbContext.FindCustomerByIdAsync(CustomerId.Of(notification.CustomerId)); + + if (customer is null) + { + throw new CustomerNotFoundException(notification.CustomerId); + } + + var mongoReadCommand = notification.ToCreateMongoRestockSubscriptionReadModels( + customer!.Id, + customer.Name.FullName + ); + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + // Schedule multiple read sides to execute here + await _commandProcessor.ScheduleAsync(new IInternalCommand[] { mongoReadCommand }, cancellationToken); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductAlreadySubscribedException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductAlreadySubscribedException.cs new file mode 100644 index 00000000..79090838 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductAlreadySubscribedException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Exceptions; + +public class ProductAlreadySubscribedException : AppException +{ + public ProductAlreadySubscribedException(long productId, string productName) + : base( + $"Product with InternalCommandId '{productId}' and Name '{productName}' is already subscribed", + StatusCodes.Status409Conflict + ) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductHasStockException.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductHasStockException.cs new file mode 100644 index 00000000..9af44d2a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Exceptions/ProductHasStockException.cs @@ -0,0 +1,11 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Exceptions; + +public class ProductHasStockException : AppException +{ + public ProductHasStockException(long productId, int quantity, string name) + : base( + $@"Product with InternalCommandId '{productId}' and name '{name}' already has available stock of '{quantity}' items." + ) { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscription.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscription.cs new file mode 100644 index 00000000..c51b4fda --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscription.cs @@ -0,0 +1,66 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Application; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscription.v1; + +public record DeleteRestockSubscription(long Id) : ITxCommand +{ + public static DeleteRestockSubscription Of(long id) + { + id.NotBeNegativeOrZero(); + return new DeleteRestockSubscription(id); + } +} + +internal class DeleteRestockSubscriptionValidator : AbstractValidator +{ + public DeleteRestockSubscriptionValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +internal class DeleteRestockSubscriptionHandler : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly ILogger _logger; + + public DeleteRestockSubscriptionHandler( + CustomersDbContext customersDbContext, + ILogger logger + ) + { + _customersDbContext = customersDbContext; + _logger = logger; + } + + public async Task Handle(DeleteRestockSubscription command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var exists = await _customersDbContext.RestockSubscriptions + .IgnoreAutoIncludes() + .SingleOrDefaultAsync(x => x.Id == command.Id, cancellationToken); + + if (exists is null) + { + throw new RestockSubscriptionNotFoundException(command.Id); + } + + // for raising a deleted domain event + exists!.Delete(); + + _customersDbContext.Entry(exists).State = EntityState.Deleted; + _customersDbContext.Entry(exists.ProductInformation).State = EntityState.Unchanged; + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("RestockSubscription with id '{InternalCommandId} removed.'", command.Id); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscriptionEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscriptionEndpoint.cs new file mode 100644 index 00000000..5dd7c996 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/DeleteRestockSubscriptionEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscription.v1; + +internal class DeleteRestockSubscriptionEndpoint : IMinimalEndpoint +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public async Task< + Results + > HandleAsync([AsParameters] DeleteRestockSubscriptionRequestParameters requestParameters) + { + var (id, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = DeleteRestockSubscription.Of(id); + + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapDelete("/{id}", HandleAsync) + .RequireAuthorization(CustomersConstants.Role.Admin) + // .Produces(StatusCodes.Status204NoContent) + // .ProducesValidationProblem() + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .WithName(nameof(DeleteRestockSubscription)) + .WithSummaryAndDescription( + nameof(DeleteRestockSubscription).Humanize(), + nameof(DeleteRestockSubscription).Humanize() + ) + .WithDisplayName(nameof(DeleteRestockSubscription).Humanize()); + } +} + +internal record DeleteRestockSubscriptionRequestParameters( + [FromRoute] long Id, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/RestockSubscriptionDeleted.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/RestockSubscriptionDeleted.cs new file mode 100644 index 00000000..acc958d5 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscription/v1/RestockSubscriptionDeleted.cs @@ -0,0 +1,62 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscription.v1; + +public record RestockSubscriptionDeleted(long RestockSubscriptionId) : DomainEvent; + +internal class RestockSubscriptionDeletedHandler : IDomainEventHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly IMapper _mapper; + private readonly CustomersDbContext _customersDbContext; + + public RestockSubscriptionDeletedHandler( + ICommandProcessor commandProcessor, + IMapper mapper, + CustomersDbContext customersDbContext + ) + { + _commandProcessor = commandProcessor; + _mapper = mapper; + _customersDbContext = customersDbContext; + } + + public async Task Handle(RestockSubscriptionDeleted notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + // var isDeleted = (bool)_customersDbContext.Entry(notification.RestockSubscription) + // .Property("IsDeleted") + // .CurrentValue!; + + var restockSubscription = await _customersDbContext.RestockSubscriptions.FirstOrDefaultAsync( + x => x.Id == notification.RestockSubscriptionId, + cancellationToken + ); + + if (restockSubscription is null) + return; + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + await _commandProcessor.SendAsync( + new UpdateMongoRestockSubscriptionReadModel( + restockSubscription.Id, + restockSubscription.CustomerId, + restockSubscription.Email, + restockSubscription.ProductInformation.Id, + restockSubscription.ProductInformation.Name, + restockSubscription.Processed, + restockSubscription.ProcessedTime, + true + ), + cancellationToken + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionByTimeEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionByTimeEndpoint.cs new file mode 100644 index 00000000..281059f5 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionByTimeEndpoint.cs @@ -0,0 +1,64 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscriptionsByTime.v1; + +internal class DeleteRestockSubscriptionByTimeEndpoint + : ICommandMinimalEndpoint< + DeleteRestockSubscriptionByTimeRequest, + DeleteRestockSubscriptionByTimeRequestParameters, + UnAuthorizedHttpProblemResult, + NotFoundHttpProblemResult, + NoContent, + ValidationProblem + > +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapDelete("/", HandleAsync) + // .Produces(StatusCodes.Status204NoContent) + // .ProducesValidationProblem() + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .RequireAuthorization(CustomersConstants.Role.Admin) + .WithName(nameof(DeleteRestockSubscriptionsByTime)) + .WithName(nameof(DeleteRestockSubscriptionsByTime)) + .WithDisplayName(nameof(DeleteRestockSubscriptionsByTime).Humanize()) + .WithSummaryAndDescription( + nameof(DeleteRestockSubscriptionsByTime).Humanize(), + nameof(DeleteRestockSubscriptionsByTime).Humanize() + ); + } + + public async Task< + Results + > HandleAsync([AsParameters] DeleteRestockSubscriptionByTimeRequestParameters requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = new DeleteRestockSubscriptionsByTime(request.From, request.To); + + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } +} + +internal record DeleteRestockSubscriptionByTimeRequestParameters( + [FromBody] DeleteRestockSubscriptionByTimeRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +internal record DeleteRestockSubscriptionByTimeRequest(DateTime? From = null, DateTime? To = null); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionsByTime.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionsByTime.cs new file mode 100644 index 00000000..81e5e8b5 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/DeletingRestockSubscriptionsByTime/v1/DeleteRestockSubscriptionsByTime.cs @@ -0,0 +1,74 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Domain; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscriptionsByTime.v1; + +public record DeleteRestockSubscriptionsByTime(DateTime? From = null, DateTime? To = null) : ITxCommand; + +internal class DeleteRestockSubscriptionsByTimeHandler : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly IDomainEventPublisher _domainEventPublisher; + private readonly ICommandProcessor _commandProcessor; + private readonly ILogger _logger; + + public DeleteRestockSubscriptionsByTimeHandler( + CustomersDbContext customersDbContext, + IDomainEventPublisher domainEventPublisher, + ICommandProcessor commandProcessor, + ILogger logger + ) + { + _customersDbContext = customersDbContext; + _domainEventPublisher = domainEventPublisher; + _commandProcessor = commandProcessor; + _logger = logger; + } + + public async Task Handle(DeleteRestockSubscriptionsByTime command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var exists = await _customersDbContext.RestockSubscriptions + .Where( + x => + (command.From == null && command.To == null) + || (command.From == null && x.Created <= command.To) + || (command.To == null && x.Created >= command.From) + || (x.Created >= command.From && x.Created <= command.To) + ) + .ToListAsync(cancellationToken: cancellationToken); + + if (exists.Any() == false) + throw new RestockSubscriptionDomainException("Not found any items to delete"); + + // instead of directly use of `UpdateMongoRestockSubscriptionsReadModelByTime` we can use this code + // foreach (var restockSubscription in exists) + // { + // restockSubscription.Delete(); + // } + + foreach (var restockSubscription in exists) + { + _customersDbContext.Entry(restockSubscription).State = EntityState.Deleted; + _customersDbContext.Entry(restockSubscription.ProductInformation).State = EntityState.Unchanged; + } + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("'{Count}' RestockSubscriptions removed.'", exists.Count); + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + await _commandProcessor.SendAsync( + new UpdateMongoRestockSubscriptionsReadModelByTime(command.From, command.To, IsDeleted: true), + cancellationToken + ); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionById.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionById.cs new file mode 100644 index 00000000..96c78525 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionById.cs @@ -0,0 +1,63 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Application; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GetRestockSubscriptionById.v1; + +public record GetRestockSubscriptionById(Guid Id) : IQuery +{ + public static GetRestockSubscriptionById Of(Guid id) + { + id.NotBeEmpty(); + return new GetRestockSubscriptionById(id); + } +} + +internal class GetRestockSubscriptionByIdValidator : AbstractValidator +{ + public GetRestockSubscriptionByIdValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +internal class GetRestockSubscriptionByIdHandler + : IQueryHandler +{ + private readonly CustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetRestockSubscriptionByIdHandler(CustomersReadUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetRestockSubscriptionById query, + CancellationToken cancellationToken + ) + { + query.NotBeNull(); + + var restockSubscription = await _unitOfWork.RestockSubscriptionsRepository.FindOneAsync( + x => x.IsDeleted == false && x.Id == query.Id, + cancellationToken + ); + + if (restockSubscription is null) + { + throw new RestockSubscriptionNotFoundException(query.Id); + } + + var subscriptionDto = _mapper.Map(restockSubscription); + + return new GetRestockSubscriptionByIdResult(subscriptionDto); + } +} + +public record GetRestockSubscriptionByIdResult(RestockSubscriptionDto RestockSubscription); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionByIdEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionByIdEndpoint.cs new file mode 100644 index 00000000..5b824176 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GetRestockSubscriptionById/v1/GetRestockSubscriptionByIdEndpoint.cs @@ -0,0 +1,70 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GetRestockSubscriptionById.v1; + +internal class GetRestockSubscriptionByIdEndpoint + : IQueryMinimalEndpoint< + GetRestockSubscriptionByIdRequestParameters, + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapGet("/{id:guid}", HandleAsync) + .RequireAuthorization(CustomersConstants.Role.Admin) + // .Produces(StatusCodes.Status200OK) + // .Produces(StatusCodes.Status401Unauthorized) + // .Produces(StatusCodes.Status400BadRequest) + // .Produces(StatusCodes.Status404NotFound) + .WithName(nameof(GetRestockSubscriptionById)) + .WithDisplayName(nameof(GetRestockSubscriptionById).Humanize()) + .WithSummaryAndDescription( + nameof(GetRestockSubscriptionById).Humanize(), + nameof(GetRestockSubscriptionById).Humanize() + ); + } + + public async Task< + Results< + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > + > HandleAsync([AsParameters] GetRestockSubscriptionByIdRequestParameters requestParameters) + { + var (id, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetRestockSubscriptionById.Of(id), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetRestockSubscriptionByIdResponse(result.RestockSubscription)); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetRestockSubscriptionByIdRequestParameters( + [FromRoute] Guid Id, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +public record GetRestockSubscriptionByIdResponse(RestockSubscriptionDto RestockSubscription); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionId.cs new file mode 100644 index 00000000..9be7d59e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionId.cs @@ -0,0 +1,68 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Application; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GettingRestockSubscriptionBySubscriptionId.v1; + +public record GetRestockSubscriptionBySubscriptionId(long RestockSubscriptionId) + : IQuery +{ + public static GetRestockSubscriptionBySubscriptionId Of(long restockSubscriptionId) + { + restockSubscriptionId.NotBeNegativeOrZero(); + return new GetRestockSubscriptionBySubscriptionId(restockSubscriptionId); + } +} + +internal class GetRestockSubscriptionBySubscriptionIdValidator + : AbstractValidator +{ + public GetRestockSubscriptionBySubscriptionIdValidator() + { + RuleFor(x => x.RestockSubscriptionId).NotEmpty(); + } +} + +internal class GetRestockSubscriptionBySubscriptionIdValidatorHandler + : IQueryHandler +{ + private readonly CustomersReadUnitOfWork _customersReadUnitOfWork; + private readonly IMapper _mapper; + + public GetRestockSubscriptionBySubscriptionIdValidatorHandler( + CustomersReadUnitOfWork customersReadUnitOfWork, + IMapper mapper + ) + { + _customersReadUnitOfWork = customersReadUnitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetRestockSubscriptionBySubscriptionId query, + CancellationToken cancellationToken + ) + { + query.NotBeNull(); + + var restockSubscription = await _customersReadUnitOfWork.RestockSubscriptionsRepository.FindOneAsync( + x => x.IsDeleted == false && x.RestockSubscriptionId == query.RestockSubscriptionId, + cancellationToken + ); + + if (restockSubscription is null) + { + throw new RestockSubscriptionNotFoundException(query.RestockSubscriptionId); + } + + var subscriptionDto = _mapper.Map(restockSubscription); + + return new GetRestockSubscriptionBySubscriptionIdResult(subscriptionDto); + } +} + +public record GetRestockSubscriptionBySubscriptionIdResult(RestockSubscriptionDto RestockSubscription); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionIdEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionIdEndpoint.cs new file mode 100644 index 00000000..ea4b718d --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptionBySubscriptionId/v1/GetRestockSubscriptionBySubscriptionIdEndpoint.cs @@ -0,0 +1,75 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GettingRestockSubscriptionBySubscriptionId.v1; + +internal class GetRestockSubscriptionBySubscriptionIdEndpoint + : IQueryMinimalEndpoint< + GetRestockSubscriptionBySubscriptionIdRequestParameters, + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + return builder + .MapGet("/{restockSubscriptionId}", HandleAsync) + .RequireAuthorization(CustomersConstants.Role.Admin) + // .Produces(StatusCodes.Status200OK) + // .Produces(StatusCodes.Status401Unauthorized) + // .Produces(StatusCodes.Status400BadRequest) + // .Produces(StatusCodes.Status404NotFound) + .WithName(nameof(GetRestockSubscriptionBySubscriptionId)) + .WithDisplayName(nameof(GetRestockSubscriptionBySubscriptionId).Humanize()) + .WithSummaryAndDescription( + nameof(GetRestockSubscriptionBySubscriptionId).Humanize(), + nameof(GetRestockSubscriptionBySubscriptionId).Humanize() + ); + } + + public async Task< + Results< + Ok, + ValidationProblem, + NotFoundHttpProblemResult, + UnAuthorizedHttpProblemResult + > + > HandleAsync([AsParameters] GetRestockSubscriptionBySubscriptionIdRequestParameters requestParameters) + { + var (restockSubscriptionId, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync( + GetRestockSubscriptionBySubscriptionId.Of(restockSubscriptionId), + cancellationToken + ); + + var response = new GetRestockSubscriptionBySubscriptionIdResponse(result.RestockSubscription); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(response); + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetRestockSubscriptionBySubscriptionIdRequestParameters( + [FromRoute] long RestockSubscriptionId, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +public record GetRestockSubscriptionBySubscriptionIdResponse(RestockSubscriptionDto RestockSubscription); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptions.cs new file mode 100644 index 00000000..417996de --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptions.cs @@ -0,0 +1,96 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FluentValidation; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GettingRestockSubscriptions.v1; + +internal record GetRestockSubscriptions : PageQuery +{ + public static GetRestockSubscriptions Of( + PageRequest pageRequest, + IEnumerable emails, + DateTime? from, + DateTime? to + ) + { + var (pageNumber, pageSize, filters, sortOrder) = pageRequest; + + return new GetRestockSubscriptionsValidator().HandleValidation( + new GetRestockSubscriptions + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder, + Emails = emails.ToList(), + From = from, + To = to + } + ); + } + + public IList Emails { get; init; } = null!; + public DateTime? From { get; init; } + public DateTime? To { get; init; } +} + +internal class GetRestockSubscriptionsValidator : AbstractValidator +{ + public GetRestockSubscriptionsValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage("Page should at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage("PageSize should at least greater than or equal to 1."); + } +} + +internal class GeRestockSubscriptionsHandler : IQueryHandler +{ + private readonly CustomersReadUnitOfWork _customersReadUnitOfWork; + private readonly IMapper _mapper; + + public GeRestockSubscriptionsHandler(CustomersReadUnitOfWork customersReadUnitOfWork, IMapper mapper) + { + _customersReadUnitOfWork = customersReadUnitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetRestockSubscriptions query, + CancellationToken cancellationToken + ) + { + var restockSubscriptions = await _customersReadUnitOfWork.RestockSubscriptionsRepository.GetByPageFilter< + RestockSubscriptionDto, + DateTime + >( + query, + _mapper.ConfigurationProvider, + sortExpression: x => x.Created, + predicate: x => + x.IsDeleted == false + && (query.Emails.Any() == false || query.Emails.Contains(x.Email)) + && ( + (query.From == null && query.To == null) + || (query.From == null && x.Created <= query.To) + || (query.To == null && x.Created >= query.From) + || (x.Created >= query.From && x.Created <= query.To) + ), + cancellationToken: cancellationToken + ); + + return new GetRestockSubscriptionsResult(restockSubscriptions); + } +} + +internal record GetRestockSubscriptionsResult(IPageList RestockSubscriptions); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptionsEndpoint.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptionsEndpoint.cs new file mode 100644 index 00000000..e92f237b --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/GettingRestockSubscriptions/v1/GetRestockSubscriptionsEndpoint.cs @@ -0,0 +1,101 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.GettingRestockSubscriptions.v1; + +internal class GetRestockSubscriptionsEndpoint + : IQueryMinimalEndpoint< + GetRestockSubscriptionsRequestParameters, + Ok, + ValidationProblem, + UnAuthorizedHttpProblemResult + > +{ + public string GroupName => RestockSubscriptionsConfigs.Tag; + public string PrefixRoute => RestockSubscriptionsConfigs.RestockSubscriptionsUrl; + public double Version => 1.0; + + public RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + // return app.MapQueryEndpoint("/") + + return builder + .MapGet("/", HandleAsync) + .RequireAuthorization() + .WithName(nameof(GetRestockSubscriptions)) + .WithSummaryAndDescription( + nameof(GetRestockSubscriptions).Humanize(), + nameof(GetRestockSubscriptions).Humanize() + ) + .WithDisplayName(nameof(GetRestockSubscriptions).Humanize()); + // .Produces("Customers fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + } + + public async Task< + Results, ValidationProblem, UnAuthorizedHttpProblemResult> + > HandleAsync([AsParameters] GetRestockSubscriptionsRequestParameters requestParameters) + { + var ( + pageNumber, + pageSize, + filters, + sortOrder, + emails, + from, + to, + context, + queryProcessor, + mapper, + cancellationToken + ) = requestParameters; + + var result = await queryProcessor.SendAsync( + GetRestockSubscriptions.Of( + new PageRequest + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + }, + emails, + from, + to + ), + cancellationToken + ); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetRestockSubscriptionsResponse(result.RestockSubscriptions)); + } +} + +public record GetRestockSubscriptionsResponse(IPageList RestockSubscriptions); + +// https://blog.codingmilitia.com/2022/01/03/getting-complex-type-as-simple-type-query-string-aspnet-core-api-controller/ +// https://benfoster.io/blog/minimal-apis-custom-model-binding-aspnet-6/ +public record GetRestockSubscriptionsRequestParameters( + int PageNumber, + int PageSize, + string? Filters, + string? SortOrder, + [FromBody] IList Emails, + DateTime? From, + DateTime? To, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery, IPageRequest; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/Events/Domain/RestockNotificationProcessed.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/Events/Domain/RestockNotificationProcessed.cs new file mode 100644 index 00000000..e4734609 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/Events/Domain/RestockNotificationProcessed.cs @@ -0,0 +1,61 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1.Events.Domain; + +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/ +// https://buildplease.com/pages/vos-in-events/ +// https://codeopinion.com/leaking-value-objects-from-your-domain/ +// https://www.youtube.com/watch?v=CdanF8PWJng +// we don't pass value-objects and domains to our commands and events, just primitive types + +public record RestockNotificationProcessed(long Id, DateTime ProcessedTime) : DomainEvent; + +internal class RestockNotificationProcessedHandler : IDomainEventHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly CustomersDbContext _customersDbContext; + + public RestockNotificationProcessedHandler( + ICommandProcessor commandProcessor, + CustomersDbContext customersDbContext + ) + { + _commandProcessor = commandProcessor; + _customersDbContext = customersDbContext; + } + + public async Task Handle(RestockNotificationProcessed notification, CancellationToken cancellationToken) + { + notification.NotBeNull(); + + var restockSubscription = await _customersDbContext.RestockSubscriptions.FirstOrDefaultAsync( + x => x.Id == notification.Id, + cancellationToken + ); + + if (restockSubscription is null) + return; + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + await _commandProcessor.SendAsync( + new UpdateMongoRestockSubscriptionReadModel( + notification.Id, + restockSubscription.CustomerId, + restockSubscription.Email, + restockSubscription.ProductInformation.Id, + restockSubscription.ProductInformation.Name, + restockSubscription.Processed, + notification.ProcessedTime + ), + cancellationToken + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/ProcessRestockNotification.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/ProcessRestockNotification.cs new file mode 100644 index 00000000..074228af --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/ProcessRestockNotification.cs @@ -0,0 +1,77 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.SendingRestockNotification.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; + +public record ProcessRestockNotification(long ProductId, int CurrentStock) : ITxCommand +{ + public static ProcessRestockNotification Of(long productId, int currentStock) + { + productId.NotBeNegativeOrZero(); + currentStock.NotBeNegativeOrZero(); + + return new ProcessRestockNotification(productId, currentStock); + } +} + +internal class ProcessRestockNotificationValidator : AbstractValidator +{ + public ProcessRestockNotificationValidator() + { + RuleFor(x => x.CurrentStock).NotEmpty(); + + RuleFor(x => x.ProductId).NotEmpty(); + } +} + +internal class ProcessRestockNotificationHandler : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly ICommandProcessor _commandProcessor; + private readonly ILogger _logger; + + public ProcessRestockNotificationHandler( + CustomersDbContext customersDbContext, + ICommandProcessor commandProcessor, + ILogger logger + ) + { + _customersDbContext = customersDbContext; + _commandProcessor = commandProcessor; + _logger = logger; + } + + public async Task Handle(ProcessRestockNotification command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var subscribedCustomers = _customersDbContext.RestockSubscriptions.Where( + x => x.ProductInformation.Id == command.ProductId && !x.Processed + ); + + if (!await subscribedCustomers.AnyAsync(cancellationToken: cancellationToken)) + return Unit.Value; + + foreach (var restockSubscription in subscribedCustomers) + { + restockSubscription!.MarkAsProcessed(DateTime.Now); + + // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing + // schedule `SendRestockNotification` for running as a internal command after commenting transaction + await _commandProcessor.ScheduleAsync( + new SendRestockNotification(restockSubscription.Id, command.CurrentStock), + cancellationToken + ); + } + + await _customersDbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Marked restock subscriptions as processed"); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionReadModel.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionReadModel.cs new file mode 100644 index 00000000..b0ebc1e2 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionReadModel.cs @@ -0,0 +1,62 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Exceptions.Application; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; + +public record UpdateMongoRestockSubscriptionReadModel( + long RestockSubscriptionId, + long CustomerId, + string Email, + long ProductId, + string ProductName, + bool Processed, + DateTime? ProcessedTime, + bool IsDeleted = false +) : InternalCommand; + +internal class UpdateMongoRestockSubscriptionReadModelHandler : ICommandHandler +{ + private readonly CustomersReadUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public UpdateMongoRestockSubscriptionReadModelHandler(CustomersReadUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle(UpdateMongoRestockSubscriptionReadModel command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var existingSubscription = await _unitOfWork.RestockSubscriptionsRepository.FindOneAsync( + x => x.RestockSubscriptionId == command.RestockSubscriptionId, + cancellationToken + ); + + if (existingSubscription is null) + { + throw new RestockSubscriptionNotFoundException(command.RestockSubscriptionId); + } + + existingSubscription = existingSubscription with + { + Processed = command.Processed, + CustomerId = command.CustomerId, + ProductName = command.ProductName, + ProductId = command.ProductId, + Email = command.Email, + ProcessedTime = command.ProcessedTime, + IsDeleted = command.IsDeleted + }; + + await _unitOfWork.RestockSubscriptionsRepository.UpdateAsync(existingSubscription, cancellationToken); + await _unitOfWork.CommitAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionsReadModelByTime.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionsReadModelByTime.cs new file mode 100644 index 00000000..c07061a7 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/ProcessingRestockNotification/v1/UpdateMongoRestockSubscriptionsReadModelByTime.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; + +public record UpdateMongoRestockSubscriptionsReadModelByTime(DateTime? From, DateTime? To, bool IsDeleted = false) + : InternalCommand; + +internal class UpdateMongoRestockSubscriptionsReadModelByTimeHandler + : ICommandHandler +{ + private readonly IMapper _mapper; + private readonly CustomersReadUnitOfWork _unitOfWork; + + public UpdateMongoRestockSubscriptionsReadModelByTimeHandler(IMapper mapper, CustomersReadUnitOfWork unitOfWork) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task Handle( + UpdateMongoRestockSubscriptionsReadModelByTime command, + CancellationToken cancellationToken + ) + { + command.NotBeNull(); + + var itemsToUpdate = await _unitOfWork.RestockSubscriptionsRepository.FindAsync( + x => + (command.From == null && command.To == null) + || (command.From == null && x.Created <= command.To) + || (command.To == null && x.Created >= command.From) + || (x.Created >= command.From && x.Created <= command.To), + cancellationToken + ); + + if (itemsToUpdate.Any() == false) + return Unit.Value; + + foreach (var restockSubscriptionReadModel in itemsToUpdate) + { + var updatedItem = restockSubscriptionReadModel with { IsDeleted = command.IsDeleted }; + await _unitOfWork.RestockSubscriptionsRepository.UpdateAsync(updatedItem, cancellationToken); + } + + await _unitOfWork.CommitAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/SendingRestockNotification/v1/SendRestockNotification.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/SendingRestockNotification/v1/SendRestockNotification.cs new file mode 100644 index 00000000..3b690469 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/SendingRestockNotification/v1/SendRestockNotification.cs @@ -0,0 +1,71 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Email; +using BuildingBlocks.Email.Options; +using FoodDelivery.Services.Customers.Shared.Data; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.SendingRestockNotification.v1; + +public record SendRestockNotification(long RestockSubscriptionId, int CurrentStock) : InternalCommand, ITxRequest; + +internal class SendRestockNotificationValidator : AbstractValidator +{ + public SendRestockNotificationValidator() + { + RuleFor(x => x.RestockSubscriptionId).NotEmpty(); + + RuleFor(x => x.CurrentStock).NotEmpty(); + } +} + +internal class SendRestockNotificationHandler : ICommandHandler +{ + private readonly CustomersDbContext _customersDbContext; + private readonly IEmailSender _emailSender; + private readonly EmailOptions _emailConfig; + private readonly ILogger _logger; + + public SendRestockNotificationHandler( + CustomersDbContext customersDbContext, + IEmailSender emailSender, + IOptions emailConfig, + ILogger logger + ) + { + _customersDbContext = customersDbContext; + _emailSender = emailSender; + _emailConfig = emailConfig.Value; + _logger = logger; + } + + public async Task Handle(SendRestockNotification command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var restockSubscription = await _customersDbContext.RestockSubscriptions.FirstOrDefaultAsync( + x => x.Id == command.RestockSubscriptionId, + cancellationToken: cancellationToken + ); + + if (_emailConfig.Enable && restockSubscription is not null) + { + await _emailSender.SendAsync( + new EmailObject( + restockSubscription.Email!, + _emailConfig.From, + "Restock Notification", + $"Your product {restockSubscription.ProductInformation.Name} is back in stock. Current stock is {command.CurrentStock}" + ) + ); + + _logger.LogInformation("Restock notification sent to email {Email}", restockSubscription.Email); + } + + return Unit.Value; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs new file mode 100644 index 00000000..6faa69ec --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs @@ -0,0 +1,22 @@ +using FoodDelivery.Services.Shared.Customers.RestockSubscriptions.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions; + +internal static class MassTransitExtensions +{ + internal static void AddRestockSubscriptionPublishers(this IRabbitMqBusFactoryConfigurator cfg) + { + cfg.Message( + e => e.SetEntityName($"{nameof(RestockSubscriptionCreatedV1).Underscore()}.input_exchange") + ); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Read/RestockSubscription.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Read/RestockSubscription.cs new file mode 100644 index 00000000..7ef72ae5 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Read/RestockSubscription.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Abstractions.Domain; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; + +public record RestockSubscription : IHaveIdentity +{ + public Guid Id { get; init; } + public long RestockSubscriptionId { get; init; } + public long CustomerId { get; init; } + public string CustomerName { get; init; } + public long ProductId { get; init; } + public string ProductName { get; init; } + public string Email { get; init; } = null!; + public DateTime Created { get; init; } + public bool Processed { get; init; } + public DateTime? ProcessedTime { get; init; } + public bool IsDeleted { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs new file mode 100644 index 00000000..98e4ee74 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs @@ -0,0 +1,80 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.ValueObjects; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Events.Domain; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.DeletingRestockSubscription.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1.Events.Domain; +using FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://github.com/dotnet/efcore/issues/29940 +public class RestockSubscription : Aggregate, IHaveSoftDelete +{ + // EF + // this constructor is needed when we have a parameter constructor that has some navigation property classes in the parameters and ef will skip it and try to find other constructor, here default constructor (maybe will fix .net 8) + public RestockSubscription() { } + + public CustomerId CustomerId { get; private set; } = default!; + public Email Email { get; private set; } = default!; + public ProductInformation ProductInformation { get; private set; } = default!; + public bool Processed { get; private set; } + public DateTime? ProcessedTime { get; private set; } + + public static RestockSubscription Create( + RestockSubscriptionId id, + CustomerId customerId, + ProductInformation productInformation, + Email email + ) + { + id.NotBeNull(); + customerId.NotBeNull(); + productInformation.NotBeNull(); + + var restockSubscription = new RestockSubscription + { + Id = id, + CustomerId = customerId, + ProductInformation = productInformation + }; + + restockSubscription.ChangeEmail(email); + + restockSubscription.AddDomainEvents( + RestockSubscriptionCreated.Of( + id, + productInformation.Id, + customerId, + productInformation.Name, + email, + restockSubscription.Created, + false + ) + ); + + return restockSubscription; + } + + public void ChangeEmail(Email email) + { + email.NotBeNull(); + Email = email; + } + + public void Delete() + { + AddDomainEvents(new RestockSubscriptionDeleted(Id)); + } + + public void MarkAsProcessed(DateTime processedTime) + { + Processed = true; + ProcessedTime = processedTime; + + AddDomainEvents(new RestockNotificationProcessed(Id, processedTime)); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsConfigs.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsConfigs.cs new file mode 100644 index 00000000..616829cd --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsConfigs.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Customers.Shared; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions; + +public class RestockSubscriptionsConfigs : IModuleConfiguration +{ + public const string Tag = "RestockSubscriptions"; + + public const string RestockSubscriptionsUrl = + $"{SharedModulesConfiguration.CustomerModulePrefixUri}/restock-subscriptions"; + + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + //// we could add event mappers manually, also they can find automatically by scanning assemblies + // builder.Services.TryAddSingleton(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + // Here we can add endpoints manually but, if our endpoint inherits from `IMinimalEndpointDefinition`, they discover automatically. + return endpoints; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsEventMapper.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsEventMapper.cs new file mode 100644 index 00000000..9257f193 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsEventMapper.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Abstractions.Domain.Events; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Messaging; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Events.Domain; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions; + +public class RestockSubscriptionsEventMapper : IIntegrationEventMapper +{ + public IReadOnlyList MapToIntegrationEvents(IReadOnlyList domainEvents) + { + return domainEvents.Select(MapToIntegrationEvent).ToList(); + } + + public IIntegrationEvent? MapToIntegrationEvent(IDomainEvent domainEvent) + { + return domainEvent switch + { + RestockSubscriptionCreated e + => new Services.Shared.Customers.RestockSubscriptions.Events.v1.Integration.RestockSubscriptionCreatedV1( + e.Id, + e.Email + ), + _ => null + }; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsMapping.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsMapping.cs new file mode 100644 index 00000000..14fda118 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/RestockSubscriptionsMapping.cs @@ -0,0 +1,41 @@ +using AutoMapper; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; +using RestockSubscription = FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read.RestockSubscription; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions; + +public class RestockSubscriptionsMapping : Profile +{ + public RestockSubscriptionsMapping() + { + CreateMap() + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email.Value)) + .ForMember(x => x.ProductName, opt => opt.MapFrom(x => x.ProductInformation.Name)) + .ForMember(x => x.ProductId, opt => opt.MapFrom(x => x.ProductInformation.Id.Value)) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId.Value)); + + CreateMap() + .ForMember(x => x.Id, opt => opt.Ignore()) + .ForMember(x => x.RestockSubscriptionId, opt => opt.MapFrom(x => x.Id.Value)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email.Value)) + .ForMember(x => x.ProductName, opt => opt.MapFrom(x => x.ProductInformation.Name)) + .ForMember(x => x.ProductId, opt => opt.MapFrom(x => x.ProductInformation.Id.Value)) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId.Value)) + .ForMember(x => x.CustomerName, opt => opt.Ignore()) + .ForMember(x => x.IsDeleted, opt => opt.Ignore()); + + CreateMap() + .ForMember(x => x.Id, opt => opt.MapFrom(x => x.RestockSubscriptionId)) + .ForMember(x => x.Email, opt => opt.MapFrom(x => x.Email)) + .ForMember(x => x.ProductName, opt => opt.MapFrom(x => x.ProductName)) + .ForMember(x => x.ProductId, opt => opt.MapFrom(x => x.ProductId)) + .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)); + + CreateMap() + .ForMember(x => x.RestockSubscriptionId, opt => opt.MapFrom(x => x.RestockSubscriptionId)) + .ForMember(x => x.Id, opt => opt.Ignore()) + .ForMember(x => x.IsDeleted, opt => opt.MapFrom(x => x.IsDeleted)); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/ProductInformation.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/ProductInformation.cs new file mode 100644 index 00000000..5431ce18 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/ProductInformation.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Customers.Products; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; + +// Here versioning Name is not important for us so we can save it on DB +public record ProductInformation +{ + // EF + public ProductInformation() { } + + public string Name { get; private set; } = default!; + public ProductId Id { get; private set; } = default!; + + public static ProductInformation Of([NotNull] ProductId? id, [NotNull] string? name) + { + name.NotBeNullOrWhiteSpace(); + id.NotBeNull(); + + return new ProductInformation { Name = name, Id = id }; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/RestockSubscriptionId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/RestockSubscriptionId.cs new file mode 100644 index 00000000..82aedc07 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/ValueObjects/RestockSubscriptionId.cs @@ -0,0 +1,16 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; + +public record RestockSubscriptionId : AggregateId +{ + // EF + private RestockSubscriptionId(long value) + : base(value) { } + + public static implicit operator long(RestockSubscriptionId id) => id.Value; + + // validations should be placed here instead of constructor + public static RestockSubscriptionId Of(long id) => new(id.NotBeNegativeOrZero()); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogApiClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogApiClient.cs new file mode 100644 index 00000000..162eafdb --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogApiClient.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Json; +using AutoMapper; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Resiliency; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Customers.Products.Models; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Timeout; +using Polly.Wrap; + +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs; + +public class CatalogApiClient : ICatalogApiClient +{ + private readonly IMapper _mapper; + private readonly HttpClient _httpClient; + private readonly CatalogsApiClientOptions _options; + private readonly AsyncPolicyWrap _combinedPolicy; + + public CatalogApiClient( + HttpClient httpClient, + IMapper mapper, + IOptions options, + IOptions policyOptions + ) + { + _mapper = mapper; + _httpClient = httpClient.NotBeNull(); + _options = options.Value; + + var retryPolicy = Policy + .Handle() + .OrResult(r => !r.IsSuccessStatusCode) + .RetryAsync(policyOptions.Value.RetryCount); + + var timeoutPolicy = Policy.TimeoutAsync(policyOptions.Value.TimeOutDuration, TimeoutStrategy.Pessimistic); + + // at any given time there will 3 parallel requests execution for specific service call and another 6 requests for other services can be in the queue. So that if the response from customer service is delayed or blocked then we don’t use too many resources + var bulkheadPolicy = Policy.BulkheadAsync(3, 6); + + // https://github.com/App-vNext/Polly#handing-return-values-and-policytresult + var circuitBreakerPolicy = Policy + .Handle() + .OrResult(r => !r.IsSuccessStatusCode) + .CircuitBreakerAsync( + policyOptions.Value.RetryCount + 1, + TimeSpan.FromSeconds(policyOptions.Value.BreakDuration) + ); + + var combinedPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy, bulkheadPolicy); + + _combinedPolicy = combinedPolicy.WrapAsync(timeoutPolicy); + } + + public async Task GetProductByIdAsync(long id, CancellationToken cancellationToken = default) + { + id.NotBeNegativeOrZero(); + + // https://github.com/App-vNext/Polly#handing-return-values-and-policytresult + var httpResponse = await _combinedPolicy.ExecuteAsync(async () => + { + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // https: //github.com/App-vNext/Polly#step-1--specify-the--exceptionsfaults-you-want-the-policy-to-handle + var httpResponse = await _httpClient.GetAsync($"{_options.ProductsEndpoint}/{id}", cancellationToken); + return httpResponse; + }); + + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // throw HttpResponseException instead of HttpRequestException (because we want detail response exception) with corresponding status code + await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); + + var productDto = await httpResponse.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + + var product = _mapper.Map(productDto?.Product); + + return product; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogsApiClientOptions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogsApiClientOptions.cs new file mode 100644 index 00000000..64a1dffa --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/CatalogsApiClientOptions.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs; + +public class CatalogsApiClientOptions +{ + public string BaseApiAddress { get; set; } = default!; + public string ProductsEndpoint { get; set; } = default!; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/GetProductByIdClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/GetProductByIdClientDto.cs new file mode 100644 index 00000000..f4d2e496 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/GetProductByIdClientDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; + +public record GetProductByIdClientDto(ProductClientDto Product); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductClientDto.cs new file mode 100644 index 00000000..3d15bf2e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductClientDto.cs @@ -0,0 +1,22 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; + +public record ProductClientDto +{ + public long Id { get; init; } + public string Name { get; init; } = default!; + public string? Description { get; init; } + public decimal Price { get; init; } + public long CategoryId { get; init; } + public string CategoryName { get; init; } = default!; + public long SupplierId { get; init; } + public string SupplierName { get; init; } = default!; + public long BrandId { get; init; } + public string BrandName { get; init; } = default!; + public int AvailableStock { get; init; } + public int RestockThreshold { get; init; } + public int MaxStockThreshold { get; init; } + public ProductStatus ProductStatus { get; init; } + public int Height { get; init; } + public int Width { get; init; } + public int Depth { get; init; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductStatus.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductStatus.cs new file mode 100644 index 00000000..d4c711cf --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/Dtos/ProductStatus.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; + +public enum ProductStatus +{ + Available = 1, + Unavailable, +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/ICatalogApiClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/ICatalogApiClient.cs new file mode 100644 index 00000000..4af35e4c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Catalogs/ICatalogApiClient.cs @@ -0,0 +1,15 @@ +using FoodDelivery.Services.Customers.Products.Models; + +namespace FoodDelivery.Services.Customers.Shared.Clients.Catalogs; + +// https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer +// https://deviq.com/domain-driven-design/anti-corruption-layer + +/// +/// CatalogApiClient acts as a anti-corruption-layer for our system. +/// An Anti-Corruption Layer (ACL) is a set of patterns placed between the domain model and other bounded contexts or third party dependencies. The intent of this layer is to prevent the intrusion of foreign concepts and models into the domain model. +/// +public interface ICatalogApiClient +{ + Task GetProductByIdAsync(long id, CancellationToken cancellationToken = default); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserClientDto.cs new file mode 100644 index 00000000..459d3756 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserClientDto.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; + +public record CreateUserClientDto( + string UserName, + string Email, + string FirstName, + string LastName, + string Password, + string ConfirmPassword +); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserIdentityClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserIdentityClientDto.cs new file mode 100644 index 00000000..67086a72 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/CreateUserIdentityClientDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; + +public record CreateUserIdentityClientDto(IdentityUserClientDto UserIdentity); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/GetUserByEmailClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/GetUserByEmailClientDto.cs new file mode 100644 index 00000000..d2dc323f --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/GetUserByEmailClientDto.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; + +public record GetUserByEmailClientDto(IdentityUserClientDto? UserIdentity); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/IdentityUserClientDto.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/IdentityUserClientDto.cs new file mode 100644 index 00000000..9336ef85 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/Dtos/IdentityUserClientDto.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; + +public record IdentityUserClientDto( + Guid Id, + string UserName, + string Email, + string PhoneNumber, + string FirstName, + string LastName +); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IIdentityApiClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IIdentityApiClient.cs new file mode 100644 index 00000000..49c77613 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IIdentityApiClient.cs @@ -0,0 +1,21 @@ +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using FoodDelivery.Services.Customers.Users.Model; + +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity; + +// https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer +// https://deviq.com/domain-driven-design/anti-corruption-layer + +/// +/// IdentityApiClient acts as a anti-corruption-layer for our system. +/// An Anti-Corruption Layer (ACL) is a set of patterns placed between the domain model and other bounded contexts or third party dependencies. The intent of this layer is to prevent the intrusion of foreign concepts and models into the domain model. +/// +public interface IIdentityApiClient +{ + Task GetUserByEmailAsync(string email, CancellationToken cancellationToken = default); + + Task CreateUserIdentityAsync( + CreateUserClientDto createUserClientDto, + CancellationToken cancellationToken = default + ); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClient.cs new file mode 100644 index 00000000..131ff966 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClient.cs @@ -0,0 +1,114 @@ +using System.Net.Http.Json; +using AutoMapper; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Resiliency; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using FoodDelivery.Services.Customers.Users.Model; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Timeout; +using Polly.Wrap; + +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity; + +public class IdentityApiClient : IIdentityApiClient +{ + private readonly IMapper _mapper; + private readonly HttpClient _httpClient; + private readonly IdentityApiClientOptions _options; + private readonly AsyncPolicyWrap _combinedPolicy; + + public IdentityApiClient( + HttpClient httpClient, + IMapper mapper, + IOptions options, + IOptions policyOptions + ) + { + _mapper = mapper; + _httpClient = httpClient.NotBeNull(); + _options = options.Value; + + var retryPolicy = Policy + .Handle() + .OrResult(r => !r.IsSuccessStatusCode) + .RetryAsync(policyOptions.Value.RetryCount); + + var timeoutPolicy = Policy.TimeoutAsync(policyOptions.Value.TimeOutDuration, TimeoutStrategy.Pessimistic); + + // at any given time there will 3 parallel requests execution for specific service call and another 6 requests for other services can be in the queue. So that if the response from customer service is delayed or blocked then we don’t use too many resources + var bulkheadPolicy = Policy.BulkheadAsync(3, 6); + + var circuitBreakerPolicy = Policy + .Handle() + .OrResult(r => !r.IsSuccessStatusCode) + .CircuitBreakerAsync( + policyOptions.Value.RetryCount + 1, + TimeSpan.FromSeconds(policyOptions.Value.BreakDuration) + ); + + var combinedPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy, bulkheadPolicy); + + _combinedPolicy = combinedPolicy.WrapAsync(timeoutPolicy); + } + + public async Task GetUserByEmailAsync(string email, CancellationToken cancellationToken = default) + { + email.NotBeNullOrWhiteSpace(); + email.NotBeInvalidEmail(); + + var httpResponse = await _combinedPolicy.ExecuteAsync(async () => + { + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // https: //github.com/App-vNext/Polly#step-1--specify-the--exceptionsfaults-you-want-the-policy-to-handle + var httpResponse = await _httpClient.GetAsync( + $"/{_options.UsersEndpoint}/by-email/{email}", + cancellationToken + ); + return httpResponse; + }); + + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // throw HttpResponseException instead of HttpRequestException (because we want detail response exception) with corresponding status code + await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); + + var userDto = await httpResponse.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + var user = _mapper.Map(userDto?.UserIdentity); + + return user; + } + + public async Task CreateUserIdentityAsync( + CreateUserClientDto createUserClientDto, + CancellationToken cancellationToken = default + ) + { + createUserClientDto.NotBeNull(); + + var httpResponse = await _combinedPolicy.ExecuteAsync(async () => + { + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // https: //github.com/App-vNext/Polly#step-1--specify-the--exceptionsfaults-you-want-the-policy-to-handle + var httpResponse = await _httpClient.PostAsJsonAsync( + _options.UsersEndpoint, + createUserClientDto, + cancellationToken + ); + return httpResponse; + }); + + // https://stackoverflow.com/questions/21097730/usage-of-ensuresuccessstatuscode-and-handling-of-httprequestexception-it-throws + // throw HttpResponseException instead of HttpRequestException (because we want detail response exception) with corresponding status code + await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); + + var userDto = await httpResponse.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + var user = _mapper.Map(userDto?.UserIdentity); + + return user; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClientOptions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClientOptions.cs new file mode 100644 index 00000000..6b4dae50 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/Identity/IdentityApiClientOptions.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Customers.Shared.Clients.Identity; + +public class IdentityApiClientOptions +{ + public string BaseApiAddress { get; set; } = default!; + public string UsersEndpoint { get; set; } = default!; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs new file mode 100644 index 00000000..9c5a03af --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using FoodDelivery.Services.Customers.Products.Models; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Customers.Shared.Clients; + +public class ClientsMappingProfile : Profile +{ + public ClientsMappingProfile() + { + CreateMap(); + CreateMap(); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomerReadRepository.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomerReadRepository.cs new file mode 100644 index 00000000..5fc06972 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomerReadRepository.cs @@ -0,0 +1,6 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Customers.Customers.Models.Reads; + +namespace FoodDelivery.Services.Customers.Shared.Contracts; + +public interface ICustomerReadRepository : IRepository { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersDbContext.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersDbContext.cs new file mode 100644 index 00000000..448d300c --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersDbContext.cs @@ -0,0 +1,13 @@ +using FoodDelivery.Services.Customers.Customers.Models; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.Shared.Contracts; + +public interface ICustomersDbContext +{ + DbSet Set() + where TEntity : class; + + public DbSet Customers { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersReadUnitOfWork.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersReadUnitOfWork.cs new file mode 100644 index 00000000..e255b30b --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/ICustomersReadUnitOfWork.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Customers.Shared.Data; + +namespace FoodDelivery.Services.Customers.Shared.Contracts; + +public interface ICustomersReadUnitOfWork : IUnitOfWork +{ + public IRestockSubscriptionReadRepository RestockSubscriptionsRepository { get; } + public ICustomerReadRepository CustomersRepository { get; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/IRestockSubscriptionReadRepository.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/IRestockSubscriptionReadRepository.cs new file mode 100644 index 00000000..b9d89d85 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Contracts/IRestockSubscriptionReadRepository.cs @@ -0,0 +1,6 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; + +namespace FoodDelivery.Services.Customers.Shared.Contracts; + +public interface IRestockSubscriptionReadRepository : IRepository { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDataSeeder.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDataSeeder.cs new file mode 100644 index 00000000..15a1cf5b --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDataSeeder.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Customers.Shared.Data; + +public class CustomersDataSeeder : IDataSeeder +{ + public int Order => 2; + + public Task SeedAllAsync() + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDbContext.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDbContext.cs new file mode 100644 index 00000000..da986a54 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersDbContext.cs @@ -0,0 +1,26 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.Shared.Data; + +public class CustomersDbContext : EfDbContextBase, ICustomersDbContext +{ + public const string DefaultSchema = "customer"; + + public CustomersDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresExtension(EfConstants.UuidGenerator); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + + base.OnModelCreating(modelBuilder); + } + + public virtual DbSet Customers { get; set; } = default!; + public virtual DbSet RestockSubscriptions { get; set; } = default!; +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersMigrationExecutor.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersMigrationExecutor.cs new file mode 100644 index 00000000..23557053 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersMigrationExecutor.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Catalogs.Shared.Data; + +public class CustomersMigrationExecutor : IMigrationExecutor +{ + private readonly CustomersDbContext _customersDbContext; + private readonly ILogger _logger; + + public CustomersMigrationExecutor(CustomersDbContext customersDbContext, ILogger logger) + { + _customersDbContext = customersDbContext; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migration worker started"); + + _logger.LogInformation("Updating customers database..."); + + await _customersDbContext.Database.MigrateAsync(cancellationToken: cancellationToken); + + _logger.LogInformation("customers database Updated"); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersReadDbContext.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersReadDbContext.cs new file mode 100644 index 00000000..db81ea4a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/CustomersReadDbContext.cs @@ -0,0 +1,20 @@ +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace FoodDelivery.Services.Customers.Shared.Data; + +public class CustomersReadDbContext : MongoDbContext +{ + public CustomersReadDbContext(IOptions options) + : base(options.Value) + { + RestockSubscriptions = GetCollection(); + Customers = GetCollection(); + } + + public IMongoCollection RestockSubscriptions { get; } + public IMongoCollection Customers { get; } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/DbContextDesignFactory.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/DbContextDesignFactory.cs new file mode 100644 index 00000000..b87ec708 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/DbContextDesignFactory.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Persistence.EfCore.Postgres; + +namespace FoodDelivery.Services.Customers.Shared.Data; + +public class CustomerDbContextDesignFactory : DbContextDesignFactoryBase +{ + public CustomerDbContextDesignFactory() + : base("PostgresOptions:ConnectionString") { } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.Designer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.Designer.cs new file mode 100644 index 00000000..743dd93a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.Designer.cs @@ -0,0 +1,352 @@ +// +using System; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Customers.Shared.Data.Migrations.Customers +{ + [DbContext(typeof(CustomersDbContext))] + [Migration("20240719224856_InitialCustomersMigration")] + partial class InitialCustomersMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "uuid-ossp"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Customers.Customers.Models.Customer", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("IdentityId") + .HasColumnType("uuid") + .HasColumnName("identity_id"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_customers_id"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_customers_identity_id"); + + b.ToTable("customers", "customer"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write.RestockSubscription", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("customer_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.Property("Processed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("processed"); + + b.Property("ProcessedTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_time"); + + b.HasKey("Id") + .HasName("pk_restock_subscriptions"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_restock_subscriptions_customer_id"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_restock_subscriptions_id"); + + b.ToTable("restock_subscriptions", "customer"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.Customers.Models.Customer", b => + { + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Address", "Address", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasColumnName("address_city"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address_country"); + + b1.Property("Detail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address_detail"); + + b1.Property("PostalCode") + .HasColumnType("text") + .HasColumnName("address_postal_code"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.BirthDate", "BirthDate", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("birth_date"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.PhoneNumber", "PhoneNumber", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("phone_number"); + + b1.HasKey("CustomerId"); + + b1.HasIndex("Value") + .IsUnique() + .HasDatabaseName("ix_customers_phone_number"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Email", "Email", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b1.HasKey("CustomerId"); + + b1.HasIndex("Value") + .IsUnique() + .HasDatabaseName("ix_customers_email"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.Customers.ValueObjects.CustomerName", "Name", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name_last_name"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.Customers.ValueObjects.Nationality", "Nationality", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasColumnName("nationality"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.Navigation("Address"); + + b.Navigation("BirthDate"); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("Nationality"); + + b.Navigation("PhoneNumber") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write.RestockSubscription", b => + { + b.HasOne("FoodDelivery.Services.Customers.Customers.Models.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_restock_subscriptions_customers_customer_id"); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Email", "Email", b1 => + { + b1.Property("RestockSubscriptionId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b1.HasKey("RestockSubscriptionId"); + + b1.ToTable("restock_subscriptions", "customer"); + + b1.WithOwner() + .HasForeignKey("RestockSubscriptionId") + .HasConstraintName("fk_restock_subscriptions_restock_subscriptions_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects.ProductInformation", "ProductInformation", b1 => + { + b1.Property("RestockSubscriptionId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Id") + .HasColumnType("bigint") + .HasColumnName("product_information_id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_name"); + + b1.HasKey("RestockSubscriptionId"); + + b1.ToTable("restock_subscriptions", "customer"); + + b1.WithOwner() + .HasForeignKey("RestockSubscriptionId") + .HasConstraintName("fk_restock_subscriptions_restock_subscriptions_id"); + }); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("ProductInformation") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.cs new file mode 100644 index 00000000..27bb5c41 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/20240719224856_InitialCustomersMigration.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FoodDelivery.Services.Customers.Shared.Data.Migrations.Customers +{ + /// + public partial class InitialCustomersMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "customer"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:uuid-ossp", ",,"); + + migrationBuilder.CreateTable( + name: "customers", + schema: "customer", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + identity_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + name_first_name = table.Column(type: "text", nullable: false), + name_last_name = table.Column(type: "text", nullable: false), + address_country = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + address_city = table.Column(type: "character varying(25)", maxLength: 25, nullable: true), + address_detail = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + address_postal_code = table.Column(type: "text", nullable: true), + nationality = table.Column(type: "character varying(25)", maxLength: 25, nullable: true), + birth_date = table.Column(type: "timestamp with time zone", nullable: true), + phone_number = table.Column(type: "character varying(15)", maxLength: 15, nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_customers", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "restock_subscriptions", + schema: "customer", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + customer_id = table.Column(type: "bigint", nullable: false), + email = table.Column(type: "text", nullable: false), + product_information_name = table.Column(type: "text", nullable: false), + product_information_id = table.Column(type: "bigint", nullable: true), + processed = table.Column(type: "boolean", nullable: false, defaultValue: false), + processed_time = table.Column(type: "timestamp with time zone", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_restock_subscriptions", x => x.id); + table.ForeignKey( + name: "fk_restock_subscriptions_customers_customer_id", + column: x => x.customer_id, + principalSchema: "customer", + principalTable: "customers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_customers_email", + schema: "customer", + table: "customers", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_customers_id", + schema: "customer", + table: "customers", + column: "id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_customers_identity_id", + schema: "customer", + table: "customers", + column: "identity_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_customers_phone_number", + schema: "customer", + table: "customers", + column: "phone_number", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_restock_subscriptions_customer_id", + schema: "customer", + table: "restock_subscriptions", + column: "customer_id"); + + migrationBuilder.CreateIndex( + name: "ix_restock_subscriptions_id", + schema: "customer", + table: "restock_subscriptions", + column: "id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "restock_subscriptions", + schema: "customer"); + + migrationBuilder.DropTable( + name: "customers", + schema: "customer"); + } + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/CustomersDbContextModelSnapshot.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/CustomersDbContextModelSnapshot.cs new file mode 100644 index 00000000..1fe51d60 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Migrations/Customers/CustomersDbContextModelSnapshot.cs @@ -0,0 +1,349 @@ +// +using System; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Customers.Shared.Data.Migrations.Customers +{ + [DbContext(typeof(CustomersDbContext))] + partial class CustomersDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "uuid-ossp"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Customers.Customers.Models.Customer", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("IdentityId") + .HasColumnType("uuid") + .HasColumnName("identity_id"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_customers_id"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_customers_identity_id"); + + b.ToTable("customers", "customer"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write.RestockSubscription", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("customer_id"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.Property("Processed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("processed"); + + b.Property("ProcessedTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_time"); + + b.HasKey("Id") + .HasName("pk_restock_subscriptions"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_restock_subscriptions_customer_id"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_restock_subscriptions_id"); + + b.ToTable("restock_subscriptions", "customer"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.Customers.Models.Customer", b => + { + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Address", "Address", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasColumnName("address_city"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address_country"); + + b1.Property("Detail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("address_detail"); + + b1.Property("PostalCode") + .HasColumnType("text") + .HasColumnName("address_postal_code"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.BirthDate", "BirthDate", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .HasColumnType("timestamp with time zone") + .HasColumnName("birth_date"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.PhoneNumber", "PhoneNumber", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("phone_number"); + + b1.HasKey("CustomerId"); + + b1.HasIndex("Value") + .IsUnique() + .HasDatabaseName("ix_customers_phone_number"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Email", "Email", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b1.HasKey("CustomerId"); + + b1.HasIndex("Value") + .IsUnique() + .HasDatabaseName("ix_customers_email"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.Customers.ValueObjects.CustomerName", "Name", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name_first_name"); + + b1.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name_last_name"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.Customers.ValueObjects.Nationality", "Nationality", b1 => + { + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)") + .HasColumnName("nationality"); + + b1.HasKey("CustomerId"); + + b1.ToTable("customers", "customer"); + + b1.WithOwner() + .HasForeignKey("CustomerId") + .HasConstraintName("fk_customers_customers_id"); + }); + + b.Navigation("Address"); + + b.Navigation("BirthDate"); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("Name") + .IsRequired(); + + b.Navigation("Nationality"); + + b.Navigation("PhoneNumber") + .IsRequired(); + }); + + modelBuilder.Entity("FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write.RestockSubscription", b => + { + b.HasOne("FoodDelivery.Services.Customers.Customers.Models.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_restock_subscriptions_customers_customer_id"); + + b.OwnsOne("BuildingBlocks.Core.Domain.ValueObjects.Email", "Email", b1 => + { + b1.Property("RestockSubscriptionId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b1.HasKey("RestockSubscriptionId"); + + b1.ToTable("restock_subscriptions", "customer"); + + b1.WithOwner() + .HasForeignKey("RestockSubscriptionId") + .HasConstraintName("fk_restock_subscriptions_restock_subscriptions_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects.ProductInformation", "ProductInformation", b1 => + { + b1.Property("RestockSubscriptionId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Id") + .HasColumnType("bigint") + .HasColumnName("product_information_id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_information_name"); + + b1.HasKey("RestockSubscriptionId"); + + b1.ToTable("restock_subscriptions", "customer"); + + b1.WithOwner() + .HasForeignKey("RestockSubscriptionId") + .HasConstraintName("fk_restock_subscriptions_restock_subscriptions_id"); + }); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("ProductInformation") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs new file mode 100644 index 00000000..bc39b54f --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs @@ -0,0 +1,19 @@ +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Customers.Shared.Extensions; + +public static class CustomersDbContextExtensions +{ + public static ValueTask FindCustomerByIdAsync(this CustomersDbContext context, CustomerId id) + { + return context.Customers.FindAsync(id); + } + + public static Task ExistsCustomerByIdAsync(this CustomersDbContext context, CustomerId id) + { + return context.Customers.AnyAsync(x => x.Id == id); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs new file mode 100644 index 00000000..c810d196 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs @@ -0,0 +1,59 @@ +using AutoMapper; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Resiliency; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddCustomHttpClients(this WebApplicationBuilder builder) + { + builder.Services.AddValidatedOptions(); + builder.Services.AddValidatedOptions(); + + AddCatalogsApiClient(builder); + + AddIdentityApiClient(builder); + + return builder; + } + + private static void AddIdentityApiClient(WebApplicationBuilder builder) + { + builder.Services.AddHttpClient( + (client, sp) => + { + var identityApiOptions = sp.GetRequiredService>(); + var policyOptions = sp.GetRequiredService>(); + identityApiOptions.Value.NotBeNull(); + var mapper = sp.GetRequiredService(); + + var baseAddress = identityApiOptions.Value.BaseApiAddress; + client.BaseAddress = new Uri(baseAddress); + return new IdentityApiClient(client, mapper, identityApiOptions, policyOptions); + } + ); + } + + private static void AddCatalogsApiClient(WebApplicationBuilder builder) + { + builder.Services.AddValidatedOptions(); + builder.Services.AddHttpClient( + (client, sp) => + { + var catalogApiOptions = sp.GetRequiredService>(); + var policyOptions = sp.GetRequiredService>(); + catalogApiOptions.Value.NotBeNull(); + var mapper = sp.GetRequiredService(); + + var baseAddress = catalogApiOptions.Value.BaseApiAddress; + client.BaseAddress = new Uri(baseAddress); + return new CatalogApiClient(client, mapper, catalogApiOptions, policyOptions); + } + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs new file mode 100644 index 00000000..6f1ffebc --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -0,0 +1,156 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Caching.Behaviours; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.IdsGenerator; +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Email; +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Integration.MassTransit; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.OpenTelemetry; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Persistence.Mongo; +using BuildingBlocks.Security.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Swagger; +using BuildingBlocks.Validation; +using BuildingBlocks.Validation.Extensions; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Customers.Customers.Extensions; +using FoodDelivery.Services.Customers.Products; +using FoodDelivery.Services.Customers.RestockSubscriptions; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Users; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder) + { + builder.Services.AddCore(typeof(CustomersMetadata).Assembly); + + builder.Services.AddCustomJwtAuthentication(builder.Configuration); + builder.Services.AddCustomAuthorization( + rolePolicies: new List + { + new(CustomersConstants.Role.Admin, new List { CustomersConstants.Role.Admin }), + new(CustomersConstants.Role.User, new List { CustomersConstants.Role.User }) + } + ); + + // https://www.michaco.net/blog/EnvironmentVariablesAndConfigurationInASPNETCoreApps#environment-variables-and-configuration + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#non-prefixed-environment-variables + builder.Configuration.AddEnvironmentVariables("food_delivery_customers_env_"); + + // https://github.com/tonerdo/dotnet-env + DotNetEnv.Env.TraversePath().Load(); + + if (builder.Environment.IsTest() == false) + { + builder.AddCustomHealthCheck(healthChecksBuilder => + { + var postgresOptions = builder.Configuration.BindOptions(); + var rabbitMqOptions = builder.Configuration.BindOptions(); + var mongoOptions = builder.Configuration.BindOptions(); + var identityApiClientOptions = builder.Configuration.BindOptions( + "IdentityApiClientOptions" + ); + var catalogsApiClientOptions = builder.Configuration.BindOptions(); + + healthChecksBuilder + .AddNpgSql( + postgresOptions.ConnectionString, + name: "CustomersService-Postgres-Check", + tags: new[] { "postgres", "database", "infra", "customers-service", "live", "ready" } + ) + .AddRabbitMQ( + rabbitMqOptions.ConnectionString, + name: "CustomersService-RabbitMQ-Check", + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "rabbitmq", "bus", "infra", "customers-service", "live", "ready" } + ) + .AddMongoDb( + mongoOptions.ConnectionString, + mongoDatabaseName: mongoOptions.DatabaseName, + "CustomersService-Mongo-Check", + tags: new[] { "mongodb", "database", "infra", "customers-service", "live", "ready" } + ) + .AddUrlGroup( + new List { new($"{catalogsApiClientOptions.BaseApiAddress}/healthz") }, + name: "Catalogs-Downstream-API-Check", + failureStatus: HealthStatus.Unhealthy, + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "uris", "downstream-services", "customers-service", "live", "ready" } + ) + .AddUrlGroup( + new List { new($"{identityApiClientOptions.BaseApiAddress}/healthz") }, + name: "Identity-Downstream-API-Check", + failureStatus: HealthStatus.Unhealthy, + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "uris", "downstream-services", "customers-service", "live", "ready" } + ); + }); + } + + builder.Services.AddEmailService(builder.Configuration); + + builder.AddCompression(); + builder.AddAppProblemDetails(); + + builder.AddCustomOpenTelemetry(); + + builder.AddCustomSerilog(); + + builder.AddCustomCors(); + + builder.AddCustomVersioning(); + builder.AddCustomSwagger(); + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddCqrs( + pipelines: new[] + { + typeof(LoggingBehavior<,>), + typeof(StreamLoggingBehavior<,>), + typeof(RequestValidationBehavior<,>), + typeof(StreamRequestValidationBehavior<,>), + typeof(StreamCachingBehavior<,>), + typeof(CachingBehavior<,>), + typeof(InvalidateCachingBehavior<,>), + typeof(EfTxBehavior<,>) + } + ); + + builder.Services.AddPostgresMessagePersistence(builder.Configuration); + + // https://blog.maartenballiauw.be/post/2022/09/26/aspnet-core-rate-limiting-middleware.html + builder.AddCustomRateLimit(); + + builder.AddCustomMassTransit( + (context, cfg) => + { + cfg.AddUsersEndpoints(context); + cfg.AddProductEndpoints(context); + + cfg.AddCustomerPublishers(); + cfg.AddRestockSubscriptionPublishers(); + }, + autoConfigEndpoints: false + ); + + builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); + + builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); + + builder.AddCustomEasyCaching(); + + builder.AddCustomHttpClients(); + + return builder; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs new file mode 100644 index 00000000..ccbba46d --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs @@ -0,0 +1,56 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Catalogs.Shared.Data; +using FoodDelivery.Services.Customers.Customers.Data.Repositories.Mongo; +using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; +using FoodDelivery.Services.Customers.RestockSubscriptions.Data.Repositories.Mongo; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddStorage(this WebApplicationBuilder builder) + { + AddPostgresWriteStorage(builder.Services, builder.Configuration); + AddMongoReadStorage(builder.Services, builder.Configuration); + + return builder; + } + + private static void AddPostgresWriteStorage(IServiceCollection services, IConfiguration configuration) + { + if (configuration.GetValue($"{nameof(PostgresOptions)}:{nameof(PostgresOptions.UseInMemory)}")) + { + services.AddDbContext( + options => options.UseInMemoryDatabase("FoodDelivery.Services.FoodDelivery.Services.Customers") + ); + + services.TryAddScoped(provider => provider.GetService()!); + services.TryAddScoped(provider => provider.GetService()!); + } + else + { + services.AddPostgresDbContext(configuration); + + // add migrations and seeders dependencies, or we could add seeders inner each modules + services.TryAddScoped(); + services.TryAddScoped(); + } + + services.TryAddScoped(provider => provider.GetRequiredService()); + } + + private static void AddMongoReadStorage(IServiceCollection services, IConfiguration configuration) + { + services.AddMongoDbContext(configuration); + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs new file mode 100644 index 00000000..cfb4666a --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Web.Problem; +using Microsoft.AspNetCore.Diagnostics; + +namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddAppProblemDetails(this WebApplicationBuilder builder) + { + builder.Services.AddCustomProblemDetails( + problemDetailsOptions => + { + // customization problem details should go here + problemDetailsOptions.CustomizeProblemDetails = problemDetailContext => + { + // with help of capture exception middleware for capturing actual exception + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // `problemDetailContext` doesn't contain real `exception` it will add in this pull request in .net 8 + if (problemDetailContext.HttpContext.Features.Get() is { } exceptionFeature) + { } + }; + }, + typeof(CustomersMetadata).Assembly); + + return builder; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs new file mode 100644 index 00000000..62d32833 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs @@ -0,0 +1,68 @@ +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; +using BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; +using FoodDelivery.Services.Catalogs; +using Serilog; + +namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationExtensions; + +public static partial class WebApplicationExtensions +{ + public static async Task UseInfrastructure(this WebApplication app) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling + // Does nothing if a response body has already been provided. when our next `DeveloperExceptionMiddleware` is written response for exception (in dev mode) when we back to `ExceptionHandlerMiddlewareImpl` because `context.Response.HasStarted` it doesn't do anything + // By default `ExceptionHandlerMiddlewareImpl` middleware register original exceptions with `IExceptionHandlerFeature` feature, we don't have this in `DeveloperExceptionPageMiddleware` and we should handle it with a middleware like `CaptureExceptionMiddleware` + // Just for handling exceptions in production mode + // https://github.com/dotnet/aspnetcore/pull/26567 + app.UseExceptionHandler(new ExceptionHandlerOptions { AllowStatusCode404Response = true }); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment() || app.Environment.IsTest()) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/handle-errrors + app.UseDeveloperExceptionPage(); + + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + app.UseCaptureException(); + } + + // this middleware should be first middleware + // request logging just log in information level and above as default + app.UseSerilogRequestLogging(opts => + { + opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest; + + // this level wil use for request logging + // https://andrewlock.net/using-serilog-aspnetcore-in-asp-net-core-3-excluding-health-check-endpoints-from-serilog-request-logging/#customising-the-log-level-used-for-serilog-request-logs + opts.GetLevel = LogEnricher.GetLogLevel; + }); + + app.UseRequestLogContextMiddleware(); + + app.UseCustomCors(); + + app.UseAuthentication(); + app.UseAuthorization(); + + await app.UsePostgresPersistenceMessage(app.Logger); + + await app.MigrateDatabases(); + + app.UseCustomRateLimit(); + + if (app.Environment.IsTest() == false) + app.UseCustomHealthCheck(); + + // Configure the prometheus endpoint for scraping metrics + // NOTE: This should only be exposed on an internal port! + // .RequireHost("*:9100"); + app.MapPrometheusScrapingEndpoint(); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs new file mode 100644 index 00000000..6040cbd9 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Catalogs; + +public static partial class WebApplicationExtensions +{ + public static async Task MigrateDatabases(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var migrationManager = scope.ServiceProvider.GetRequiredService(); + + await migrationManager.ExecuteAsync(CancellationToken.None); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs new file mode 100644 index 00000000..cd3e3e0e --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs @@ -0,0 +1,51 @@ +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core; +using FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; +using FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationExtensions; + +namespace FoodDelivery.Services.Customers.Shared; + +public class SharedModulesConfiguration : ISharedModulesConfiguration +{ + public const string CustomerModulePrefixUri = "api/v{version:apiVersion}/customers"; + + public WebApplicationBuilder AddSharedModuleServices(WebApplicationBuilder builder) + { + builder.AddInfrastructure(); + + builder.AddStorage(); + + return builder; + } + + public async Task ConfigureSharedModule(WebApplication app) + { + await app.UseInfrastructure(); + + ServiceActivator.Configure(app.Services); + + return app; + } + + public IEndpointRouteBuilder MapSharedModuleEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints + .MapGet( + "/", + (HttpContext context) => + { + var requestId = context.Request.Headers.TryGetValue( + "X-Request-InternalCommandId", + out var requestIdHeader + ) + ? requestIdHeader.FirstOrDefault() + : string.Empty; + + return $"Customers Service Apis, RequestId: {requestId}"; + } + ) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/TestDomainEvent.cs b/src/Services/Customers/FoodDelivery.Services.Customers/TestDomainEvent.cs new file mode 100644 index 00000000..b3d6b3d2 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/TestDomainEvent.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Domain.Events.Internal; + +namespace FoodDelivery.Services.Customers; + +public record TestDomainEvent(string Data) : DomainEvent; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegration.cs new file mode 100644 index 00000000..7e62bbf3 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegration.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Customers; + +public record TestIntegration(string Data) : IntegrationEvent; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegrationConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegrationConsumer.cs new file mode 100644 index 00000000..0c003cc9 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/TestIntegrationConsumer.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Abstractions.Messaging; + +namespace FoodDelivery.Services.Customers; + +public class TestIntegrationConsumer : IIntegrationEventHandler +{ + public Task Handle(TestIntegration notification, CancellationToken cancellationToken) + { + Console.WriteLine("Data is: " + notification.Data); + return Task.CompletedTask; + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs new file mode 100644 index 00000000..246149a0 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs @@ -0,0 +1,25 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using MassTransit; + +namespace FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; + +public class UserRegisteredConsumer : IConsumer +{ + private readonly ICommandProcessor _commandProcessor; + + public UserRegisteredConsumer(ICommandProcessor commandProcessor) + { + _commandProcessor = commandProcessor; + } + + public async Task Consume(ConsumeContext context) + { + var userRegistered = context.Message; + if (userRegistered.Roles is null || !userRegistered.Roles.Contains(CustomersConstants.Role.User)) + return; + + await _commandProcessor.SendAsync(new CreateCustomer(userRegistered.Email)); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs new file mode 100644 index 00000000..85587ff2 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs @@ -0,0 +1,40 @@ +using FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Customers.Users; + +internal static class MassTransitExtensions +{ + internal static void AddUsersEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) + { + cfg.ReceiveEndpoint( + nameof(UserRegisteredV1).Underscore(), + re => + { + // turns off default fanout settings + re.ConfigureConsumeTopology = false; + + // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + re.SetQuorumQueue(); + + re.Bind( + $"{nameof(UserRegisteredV1).Underscore()}.input_exchange", + e => + { + e.RoutingKey = nameof(UserRegisteredV1).Underscore(); + e.ExchangeType = ExchangeType.Direct; + } + ); + + // https://github.com/MassTransit/MassTransit/discussions/3117 + // https://masstransit-project.com/usage/configuration.html#receive-endpoints + re.ConfigureConsumer(context); + + re.RethrowFaultedMessages(); + } + ); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Users/Model/IdentityUser.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Model/IdentityUser.cs new file mode 100644 index 00000000..6589e2c3 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Model/IdentityUser.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Customers.Users.Model; + +public record UserIdentity( + Guid Id, + string UserName, + string Email, + string PhoneNumber, + string FirstName, + string LastName +); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/readme.md b/src/Services/Customers/FoodDelivery.Services.Customers/readme.md new file mode 100644 index 00000000..3509d266 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/readme.md @@ -0,0 +1,6 @@ +#### Migration Scripts + +```bash +dotnet ef migrations add InitialCustomersMigration -o Shared/Data/Migrations/Customers -c CustomersDbContext +dotnet ef database update -c CustomersDbContext +``` diff --git a/src/Services/Customers/dev.Dockerfile b/src/Services/Customers/dev.Dockerfile new file mode 100644 index 00000000..eb75e677 --- /dev/null +++ b/src/Services/Customers/dev.Dockerfile @@ -0,0 +1,110 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Customers/Directory.Build.props ./Services/Customers/ + +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Customers/FoodDelivery.Services.Customers/FoodDelivery.Services.Customers.csproj ./Services/Customers/FoodDelivery.Services.Customers/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj ./Services/Customers/FoodDelivery.Services.Customers.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Customers.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN --mount=type=cache,id=customers_nuget,target=/root/.nuget/packages \ + dotnet restore ./Services/Customers/FoodDelivery.Services.Customers.Api/FoodDelivery.Services.Customers.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers.Api/ ./Services/Customers/FoodDelivery.Services.Customers.Api/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers/ ./Services/Customers/FoodDelivery.Services.Customers/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Customers/FoodDelivery.Services.Customers.Api/ + +RUN --mount=type=cache,id=customers_nuget,target=/root/.nuget/packages\ + dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN --mount=type=cache,id=customers_nuget,target=/root/.nuget/packages\ + dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Customers.Api.dll"] diff --git a/src/Services/Customers/migrations.bat b/src/Services/Customers/migrations.bat new file mode 100644 index 00000000..29e4a7a4 --- /dev/null +++ b/src/Services/Customers/migrations.bat @@ -0,0 +1,5 @@ + +IF "%1"=="init-context" dotnet ef migrations add InitialCustomersMigration -o \FoodDelivery.Services.Customer\Shared\Data\Migrations\Customer --project .\FoodDelivery.Services.Customers\FoodDelivery.Services.Customers.csproj -c CustomersDbContext --verbose & goto exit +IF "%1"=="update-context" dotnet ef database update -c CustomersDbContext --verbose --project .\FoodDelivery.Services.Customers\FoodDelivery.Services.Customers.csproj & goto exit + +:exit \ No newline at end of file diff --git a/src/Services/Customers/nuget.config b/src/Services/Customers/nuget.config new file mode 100644 index 00000000..6ce97590 --- /dev/null +++ b/src/Services/Customers/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Customers/watch.Dockerfile b/src/Services/Customers/watch.Dockerfile new file mode 100644 index 00000000..93ebc731 --- /dev/null +++ b/src/Services/Customers/watch.Dockerfile @@ -0,0 +1,50 @@ +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilation +FROM mcr.microsoft.com/dotnet/sdk:8.0 as builder + +WORKDIR /src + +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Customers/Directory.Build.props ./Services/Customers/ + +# TODO: Using wildcard to copy all files in the directory. + +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +# Copy project files + +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers.Api/ ./Services/Customers/FoodDelivery.Services.Customers.Api/ +COPY ./src/Services/Customers/FoodDelivery.Services.Customers/ ./Services/Customers/FoodDelivery.Services.Customers/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Customers/FoodDelivery.Services.Customers.Api/ + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +RUN dotnet watch run FoodDelivery.Services.Customers.Api.csproj --launch-profile Customers.Api.LiveRecompilation diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Controllers/WeatherForecastController.cs b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..f06dc74b --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Controllers/WeatherForecastController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; + +namespace FoodDelivery.Services.GroceryStores.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + return Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + } + ) + .ToArray(); + } +} diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/FoodDelivery.Services.GroceryStores.Api.csproj b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/FoodDelivery.Services.GroceryStores.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/FoodDelivery.Services.GroceryStores.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Program.cs b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Program.cs new file mode 100644 index 00000000..210fe058 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Program.cs @@ -0,0 +1,26 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Properties/launchSettings.json b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Properties/launchSettings.json new file mode 100644 index 00000000..ce8b7970 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:49710", + "sslPort": 44334 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7019;http://localhost:5147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/WeatherForecast.cs b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/WeatherForecast.cs new file mode 100644 index 00000000..a4b4ebe7 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace FoodDelivery.Services.GroceryStores.Api; + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } +} diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.Development.json b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.json b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/Class1.cs b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/Class1.cs new file mode 100644 index 00000000..a2a983d4 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.GroceryStores; + +public class Class1 { } diff --git a/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/FoodDelivery.Services.GroceryStores.csproj b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/FoodDelivery.Services.GroceryStores.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/GroceryStores/FoodDelivery.Services.GroceryStores/FoodDelivery.Services.GroceryStores.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Identity/Directory.Build.props b/src/Services/Identity/Directory.Build.props new file mode 100644 index 00000000..a5fb499e --- /dev/null +++ b/src/Services/Identity/Directory.Build.props @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Identity/Dockerfile b/src/Services/Identity/Dockerfile new file mode 100644 index 00000000..68823cb3 --- /dev/null +++ b/src/Services/Identity/Dockerfile @@ -0,0 +1,100 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Identity/Directory.Build.props ./Services/Identity/ + +# TODO: Using wildcard to copy all files in the directory. +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj ./Services/Identity/FoodDelivery.Services.Identity/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj ./Services/Identity/FoodDelivery.Services.Identity.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Identity.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN dotnet restore ./Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity.Api/ ./Services/Identity/FoodDelivery.Services.Identity.Api/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity/ ./Services/Identity/FoodDelivery.Services.Identity/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Identity/FoodDelivery.Services.Identity.Api/ + +RUN dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Identity.Api.dll"] diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj b/src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj new file mode 100644 index 00000000..e3f0134c --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj @@ -0,0 +1,35 @@ + + + + true + + + + + + + + identity + dev + mcr.microsoft.com/dotnet/aspnet:latest + 526a05d0-3da0-4d64-b60b-47482dd0cbc0 + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/IdentityApiMetadata.cs b/src/Services/Identity/FoodDelivery.Services.Identity.Api/IdentityApiMetadata.cs new file mode 100644 index 00000000..ae025c1a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/IdentityApiMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Identity.Api; + +public class IdentityApiMetadata { } diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/Middlewares/RevokeTokenMiddleware.cs b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Middlewares/RevokeTokenMiddleware.cs new file mode 100644 index 00000000..6080fb71 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Middlewares/RevokeTokenMiddleware.cs @@ -0,0 +1,55 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Web.Extensions; +using EasyCaching.Core; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Identity.Api.Middlewares; + +public class RevokeAccessTokenMiddleware : IMiddleware +{ + private readonly IEasyCachingProvider _cachingProvider; + + public RevokeAccessTokenMiddleware( + IEasyCachingProviderFactory cachingProviderFactory, + IOptions options + ) + { + _cachingProvider = cachingProviderFactory.GetCachingProvider(options.Value.DefaultCacheType); + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (context.User.Identity is null || string.IsNullOrWhiteSpace(context.User.Identity.Name)) + { + await next(context); + return; + } + + var accessToken = GetTokenFromHeader(context); + var userName = context.User.Identity.Name; + + var revokedToken = await _cachingProvider.GetAsync($"{userName}_{accessToken}_revoked_token"); + if (string.IsNullOrWhiteSpace(revokedToken.Value)) + { + await next(context); + return; + } + + throw new UnAuthorizedException("Access token is revoked, User in not authorized to access this resource"); + } + + private static string GetTokenFromHeader(HttpContext context) + { + var authorizationHeader = context.Request.Headers.Get("authorization"); + return authorizationHeader; + } +} + +public static class MiddlewareExtensions +{ + public static IApplicationBuilder UseRevokeAccessTokenMiddleware(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/Program.cs b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Program.cs new file mode 100644 index 00000000..16120198 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Program.cs @@ -0,0 +1,92 @@ +using Bogus; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Web; +using BuildingBlocks.Swagger; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Modules.Extensions; +using FoodDelivery.Services.Identity; +using FoodDelivery.Services.Identity.Api.Middlewares; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Spectre.Console; + +AnsiConsole.Write(new FigletText("Identity Service").Centered().Color(Color.FromInt32(new Faker().Random.Int(1, 255)))); + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis +// https://benfoster.io/blog/mvc-to-minimal-apis-aspnet-6/ +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider( + (context, options) => + { + var isDevMode = + context.HostingEnvironment.IsDevelopment() + || context.HostingEnvironment.IsTest() + || context.HostingEnvironment.IsStaging(); + + // Handling Captive Dependency Problem + // https://ankitvijay.net/2020/03/17/net-core-and-di-beware-of-captive-dependency/ + // https://levelup.gitconnected.com/top-misconceptions-about-dependency-injection-in-asp-net-core-c6a7afd14eb4 + // https://blog.ploeh.dk/2014/06/02/captive-dependency/ + // https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/web-host?view=aspnetcore-7.0&viewFallbackFrom=aspnetcore-2.2#scope-validation + // CreateDefaultBuilder and WebApplicationBuilder in minimal apis sets `ServiceProviderOptions.ValidateScopes` and `ServiceProviderOptions.ValidateOnBuild` to true if the app's environment is Development. + // check dependencies are used in a valid life time scope + options.ValidateScopes = isDevMode; + // validate dependencies on the startup immediately instead of waiting for using the service - Issue with masstransit #85 + // options.ValidateOnBuild = isDevMode; + } +); + +// https://www.talkingdotnet.com/disable-automatic-model-state-validation-in-asp-net-core-2-1/ +builder.Services.Configure(options => +{ + options.SuppressModelStateInvalidFilter = true; +}); + +builder.Services.TryAddSingleton(); + +builder.Services.AddValidatedOptions(); + +// register endpoints +builder.AddMinimalEndpoints(typeof(IdentityMetadata).Assembly); + +/*----------------- Module Services Setup ------------------*/ +builder.AddModulesServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsTest()) +{ + app.Services.ValidateDependencies( + builder.Services, + typeof(IdentityMetadata).Assembly, + Assembly.GetExecutingAssembly() + ); +} + +/*----------------- Module Middleware Setup ------------------*/ +await app.ConfigureModules(); + +// https://thecodeblogger.com/2021/05/27/asp-net-core-web-application-routing-and-endpoint-internals/ +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#routing-basics +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#endpoints +// https://stackoverflow.com/questions/57846127/what-are-the-differences-between-app-userouting-and-app-useendpoints +// in .net 6 and above we don't need UseRouting and UseEndpoints but if ordering is important we should write it +// app.UseRouting(); + +app.UseRevokeAccessTokenMiddleware(); + +/*----------------- Module Routes Setup ------------------*/ +app.MapModulesEndpoints(); + +// automatic discover minimal endpoints +app.MapMinimalEndpoints(); + +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("docker")) +{ + // swagger middleware should register last to discover all endpoints and its versions correctly + app.UseCustomSwagger(); +} + +await app.RunAsync(); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/Properties/launchSettings.json b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Properties/launchSettings.json new file mode 100644 index 00000000..bd5bef99 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/Properties/launchSettings.json @@ -0,0 +1,48 @@ +{ + "profiles": { + "Identity.Api.Http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:7000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Identity.Api.Https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7001;http://localhost:7000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Identity.Api.Watch": { + "commandName": "Executable", + "executablePath": "dotnet", + "workingDirectory": "$(ProjectDir)", + "hotReloadEnabled": true, + "hotReloadProfile": "aspnetcore", + "commandLineArgs": "watch -lp Identity.Api.Http", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Identity.Api.LiveRecompilation": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:7000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.development.json b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.development.json new file mode 100644 index 00000000..759f76f4 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.development.json @@ -0,0 +1,28 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + } + }, + "Authentication": { + "Schemes": { + "Bearer": { + "ValidAudiences": [ + "https://localhost:7000", + "https://localhost:8000", + "https://localhost:5000", + "http://localhost:4000" + ], + "ValidIssuer": "dotnet-user-jwts" + } + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json new file mode 100644 index 00000000..bf2f2007 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.docker.json @@ -0,0 +1,32 @@ +{ + "PostgresOptions": { + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_identity;User Id=postgres;Password=postgres;", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "RabbitMqOptions": { + "Host": "rabbitmq", + "UserName": "guest", + "Password": "guest" + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_identity;User Id=postgres;Password=postgres;", + "Enabled": true + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.json b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.json new file mode 100644 index 00000000..a918add4 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.json @@ -0,0 +1,79 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning", + "MassTransit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "PostgresOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_identity;User Id=postgres;Password=postgres;", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "IdentityOptions": { + "Password": { + "RequiredLength": 6, + "RequireDigit": false, + "RequireNonAlphanumeric": false + }, + "User": { + "MaxPasswordAge": "0", + "RequireUniqueEmail": true + }, + "Lockout": { + "DefaultLockoutTimeSpan": "0:15:0", + "AllowedForNewUsers": true, + "MaxFailedAccessAttempts": 3 + } + }, + "EmailOptions": { + "From": "info@my-food-delivery-service.com", + "DisplayName": "Food Delivery Application Mail", + "Enable": true, + "MimeKitOptions": { + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "", + "Password": "" + } + }, + "RabbitMqOptions": { + "Host": "localhost", + "UserName": "guest", + "Password": "guest" + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_identity;User Id=postgres;Password=postgres;", + "Enabled": true + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "HealthOptions": { + "Enabled": false + }, + "ConfigurationFolder": "config-files/" +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.test.json b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.test.json new file mode 100644 index 00000000..ade11263 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/appsettings.test.json @@ -0,0 +1,11 @@ +{ + "MongoContainerOptions": { + "DatabaseName": "food-delivery-services-identity_test", + "ImageName": "mongo:latest" + }, + "PostgresContainerOptions": { + "DatabaseName": "food_delivery_services_identity_test", + "ImageName": "postgres", + "MigrationAssembly": "FoodDelivery.Services.Identity" + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity.Api/tempkey.jwk b/src/Services/Identity/FoodDelivery.Services.Identity.Api/tempkey.jwk new file mode 100644 index 00000000..065cb909 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity.Api/tempkey.jwk @@ -0,0 +1 @@ +{"alg":"RS256","d":"FIIDUoBGwymP8srsQDQovbDmw2QJRXC9jrrIwAKD9WM7HBk1MEjdOtHxKDx5ueAhNkTCP48OFUU5VzimVWsi-SCfu7beujdDIMaXlry6PTGh_UVdD1M67lSxY4hsYflL1-lpSFrZcc2dA5qFP9zrkLBfFDegGemd9oyV4hCYei0t0hBN8tT-8XeG30lhzHHPP5depCPD-z-hNQrEFNp4zqRcTbtSyc22FelWbfXevkAt3uE5iteuoTMK2BDBwoUi1UCU5iSqtL_bWh-chuDAyHJgXSAZWekqTz4selthQDL1G46eoJOMZZzG3Z-Fuma1TGrOLvNaBVAJ7ItylOUhyQ","dp":"1nJqnLtttznuS4ns4_SKjRsD88KZb-T3Ei4g0Jtdy-RJtr9FDoNeyfFN7TydUXO9YJWitwspkSu-l7GgxIojB8WHFg1gt_5qBYd3mQ2Ztlu2L_eohvotTyM3DDpmlyL-QsG1MevcVZC9sYVoU9eYQG-NJig0isdweLyPtwb1iYs","dq":"kTo6gORQu2uU2QCvy_FUfjwn-lw-wAkgNvX2jQqCbvCo24Pvw7dG9Bmqy04oISm85u1y2Jp0_ctFHKS2tGMfbyeTz5AXK9ptz-UsRCIQp6sFQwt8k2fGMBd23QGYYYOkrS7OoHs6FGJUmjm0A0qdExwUYKx0h0Zq_fvqLk4bW1M","e":"AQAB","kid":"F995BA847B7E6133F95A40CF25C19712","kty":"RSA","n":"6o8jUWJBvVsw5ISPG0V4cV_lvi5-abTly6dWWglKm0Ly1b_msKff_z-HpdM7WM_MGluzWST6Bx4M4wx9Xfoev-o8aymoqPftxf8fbDjfeLY_CP7xSY0w8k_exTJNfOcmvo8y6-a0KtkT-vmxp0deVzT6CiDlh85QP2BuvS80CekS6hKfAlqzJ7O_shqZddxCRfL2fyX9L_F111LISRKsbCFtiBoYnxMLFdfMFVwNNXQeH3Y08RCoBW2uvPoiQK38RIFbEWlcYvWZjGNKCoTxeAo0PxkOoDCN0dtSGQVLWd-FO843vPl22Cvxip0oO6VRHh6KkZwt01LsuVn3UvE7MQ","p":"8q-0dRnreUbmRjnrmBZhEwUTODyRM0-4dCAAIsgqXQ_2AVbgcMeWABkAkFWO7ATtncL3Z6zcSaKKBEn3lX6gIWwJy0zIzmtd9Yeh98GqFyA7mwrNOUEGBa_q65Q0FMihLpRYcfwca_kQtN5K3RKbS4QN1cobgBYL6OlWc46Nhcc","q":"921LTzUO6Ycox4dcy63F5gJ6IxLZDMHFLuY8Kxrt9Fj9c8iEx4MdnhTnH4ttIbhkXNchOqIPquxrkl0L8Jlbnzqaq8r9n691Uvmh1vQt4M_vzVHw8Fs2I1KdlN62yCTv07ols-sew4PSv9PMeEmiS4QUV8W8gKzVY-6C-RLx10c","qi":"HutX9x80Gm_tV3OqczQgaxcPO-ljYtPFONI5UxMB-4IOVnyhmYbiCccOfroWcwR1Xsyd-sLjan-CnZ47lUoSCCGPsH5YsqHVf8zgfA4-UIHJO8bwmZCQg-QbjPlq9OTHjoYEvrfBltHz5Zz4sbL41b0HRhb8yeoOuszuxo8cgso"} \ No newline at end of file diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj b/src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj new file mode 100644 index 00000000..9c2125d2 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj @@ -0,0 +1,64 @@ + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/AccessTokenConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/AccessTokenConfiguration.cs new file mode 100644 index 00000000..c5d6d956 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/AccessTokenConfiguration.cs @@ -0,0 +1,24 @@ +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +public class AccessTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AccessTokens"); + + builder.Property("InternalCommandId").ValueGeneratedOnAdd(); + builder.HasKey("InternalCommandId"); + + builder.HasIndex(x => new { x.Token, x.UserId }).IsUnique(); + + builder.HasOne(rt => rt.ApplicationUser).WithMany(au => au.AccessTokens).HasForeignKey(x => x.UserId); + + builder.Property(rt => rt.Token); + + builder.Property(rt => rt.CreatedAt); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationRoleConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationRoleConfiguration.cs new file mode 100644 index 00000000..0db9b25a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationRoleConfiguration.cs @@ -0,0 +1,15 @@ +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +internal class ApplicationRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model#add-navigation-properties + // Each Role can have many entries in the UserRole join table + builder.HasMany(e => e.UserRoles).WithOne(e => e.Role).HasForeignKey(ur => ur.RoleId).IsRequired(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationUserConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationUserConfiguration.cs new file mode 100644 index 00000000..102f8a81 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/ApplicationUserConfiguration.cs @@ -0,0 +1,34 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +internal class ApplicationUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.FirstName).HasMaxLength(100).IsRequired(); + builder.Property(x => x.LastName).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UserName).HasMaxLength(50).IsRequired(); + builder.Property(x => x.NormalizedUserName).HasMaxLength(50).IsRequired(); + builder.Property(x => x.Email).HasMaxLength(50).IsRequired(); + builder.Property(x => x.NormalizedEmail).HasMaxLength(50).IsRequired(); + builder.Property(x => x.PhoneNumber).HasMaxLength(15).IsRequired(false); + + builder.Property(x => x.CreatedAt).HasDefaultValueSql(EfConstants.DateAlgorithm); + + builder + .Property(x => x.UserState) + .HasDefaultValue(UserState.Active) + .HasConversion(x => x.ToString(), x => (UserState)Enum.Parse(typeof(UserState), x)); + + builder.HasIndex(x => x.Email).IsUnique(); + builder.HasIndex(x => x.NormalizedEmail).IsUnique(); + + // https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model#add-navigation-properties + // Each User can have many entries in the UserRole join table + builder.HasMany(e => e.UserRoles).WithOne(e => e.User).HasForeignKey(ur => ur.UserId).IsRequired(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/EmailVerificationCodeConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/EmailVerificationCodeConfiguration.cs new file mode 100644 index 00000000..6b3b85cc --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/EmailVerificationCodeConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +public class EmailVerificationCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EmailVerificationCodes"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).ValueGeneratedOnAdd(); + builder.Property(x => x.Email).HasMaxLength(50).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(6).IsFixedLength().IsRequired(); + builder.Property(x => x.SentAt).Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw); + builder.Property(x => x.UsedAt).Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Throw); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/PasswordResetCodeConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/PasswordResetCodeConfiguration.cs new file mode 100644 index 00000000..327b4eee --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/PasswordResetCodeConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +public class PasswordResetCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PasswordResetCodes"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).ValueGeneratedOnAdd(); + builder.Property(x => x.Email).HasMaxLength(50).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(6).IsFixedLength().IsRequired(); + builder.Property(x => x.SentAt).Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw); + builder.Property(x => x.UsedAt).Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Throw); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/RefreshTokenConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/RefreshTokenConfiguration.cs new file mode 100644 index 00000000..d7617fee --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/EntityConfigurations/RefreshTokenConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Identity.Data.EntityConfigurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens"); + + builder.Property("InternalCommandId").ValueGeneratedOnAdd(); + builder.HasKey("InternalCommandId"); + + builder.HasIndex(x => new { x.Token, x.UserId }).IsUnique(); + + builder.HasOne(rt => rt.ApplicationUser).WithMany(au => au.RefreshTokens).HasForeignKey(x => x.UserId); + + builder.Property(rt => rt.Token).HasMaxLength(100); + builder.Property(rt => rt.CreatedAt); + builder.Ignore(rt => rt.IsActive); + builder.Ignore(rt => rt.IsExpired); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/IdentityDataSeeder.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/IdentityDataSeeder.cs new file mode 100644 index 00000000..a3aadb42 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Data/IdentityDataSeeder.cs @@ -0,0 +1,69 @@ +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Identity.Data; + +public class IdentityDataSeeder : IDataSeeder +{ + private readonly RoleManager _roleManager; + private readonly UserManager _userManager; + + public IdentityDataSeeder(UserManager userManager, RoleManager roleManager) + { + _userManager = userManager; + _roleManager = roleManager; + } + + public async Task SeedAllAsync() + { + await SeedRoles(); + await SeedUsers(); + } + + public int Order => 1; + + private async Task SeedRoles() + { + if (!await _roleManager.RoleExistsAsync(ApplicationRole.Admin.Name)) + await _roleManager.CreateAsync(ApplicationRole.Admin); + + if (!await _roleManager.RoleExistsAsync(ApplicationRole.User.Name)) + await _roleManager.CreateAsync(ApplicationRole.User); + } + + private async Task SeedUsers() + { + if (await _userManager.FindByEmailAsync("mehdi@test.com") == null) + { + var user = new ApplicationUser + { + UserName = "mehdi", + FirstName = "Mehdi", + LastName = "test", + Email = "mehdi@test.com", + }; + + var result = await _userManager.CreateAsync(user, "123456"); + + if (result.Succeeded) + await _userManager.AddToRoleAsync(user, ApplicationRole.Admin.Name); + } + + if (await _userManager.FindByEmailAsync("mehdi2@test.com") == null) + { + var user = new ApplicationUser + { + UserName = "mehdi2", + FirstName = "Mehdi", + LastName = "Test", + Email = "mehdi2@test.com" + }; + + var result = await _userManager.CreateAsync(user, "123456"); + + if (result.Succeeded) + await _userManager.AddToRoleAsync(user, ApplicationRole.User.Name); + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Dtos/v1/RefreshTokenDto.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Dtos/v1/RefreshTokenDto.cs new file mode 100644 index 00000000..74159314 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Dtos/v1/RefreshTokenDto.cs @@ -0,0 +1,14 @@ +namespace FoodDelivery.Services.Identity.Identity.Dtos.v1; + +public record RefreshTokenDto +{ + public Guid UserId { get; set; } + public string Token { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public DateTime ExpireAt { get; set; } + public string CreatedByIp { get; set; } = default!; + public bool IsExpired { get; set; } + public bool IsRevoked { get; set; } + public bool IsActive { get; set; } + public DateTime? RevokedAt { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/EmailNotConfirmedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/EmailNotConfirmedException.cs new file mode 100644 index 00000000..b6f3809f --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/EmailNotConfirmedException.cs @@ -0,0 +1,15 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +// https://stackoverflow.com/questions/36283377/http-status-for-email-not-verified +public class EmailNotConfirmedException : AppException +{ + public EmailNotConfirmedException(string email) + : base($"Email not confirmed for email address `{email}`", StatusCodes.Status422UnprocessableEntity) + { + Email = email; + } + + public string Email { get; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/InvalidTokenException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/InvalidTokenException.cs new file mode 100644 index 00000000..7bfd7940 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/InvalidTokenException.cs @@ -0,0 +1,11 @@ +using System.Net; +using System.Security.Claims; +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class InvalidTokenException : AppException +{ + public InvalidTokenException(ClaimsPrincipal? claimsPrincipal) + : base("access_token is invalid!") { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PasswordIsInvalidException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PasswordIsInvalidException.cs new file mode 100644 index 00000000..c69efe67 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PasswordIsInvalidException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class PasswordIsInvalidException : AppException +{ + public PasswordIsInvalidException(string message) + : base(message, StatusCodes.Status403Forbidden) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PhoneNumberNotConfirmedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PhoneNumberNotConfirmedException.cs new file mode 100644 index 00000000..8f46edca --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/PhoneNumberNotConfirmedException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class PhoneNumberNotConfirmedException : AppException +{ + public PhoneNumberNotConfirmedException(string phone) + : base($"The phone number '{phone}' is not confirmed yet.", StatusCodes.Status422UnprocessableEntity) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RefreshTokenNotFoundException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RefreshTokenNotFoundException.cs new file mode 100644 index 00000000..b60b8e78 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RefreshTokenNotFoundException.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Core.Exception.Types; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class RefreshTokenNotFoundException : AppException +{ + public RefreshTokenNotFoundException(RefreshToken? refreshToken) + : base("Refresh token not found.", StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RequiresTwoFactorException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RequiresTwoFactorException.cs new file mode 100644 index 00000000..abdded09 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/RequiresTwoFactorException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class RequiresTwoFactorException : AppException +{ + public RequiresTwoFactorException(string message) + : base(message, StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/UserLockedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/UserLockedException.cs new file mode 100644 index 00000000..54897d94 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Exceptions/UserLockedException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Exceptions; + +public class UserLockedException : AppException +{ + public UserLockedException(string userId) + : base($"userId '{userId}' has been locked.", StatusCodes.Status403Forbidden) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingJwtToken/v1/GenerateJwtToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingJwtToken/v1/GenerateJwtToken.cs new file mode 100644 index 00000000..27fdca23 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingJwtToken/v1/GenerateJwtToken.cs @@ -0,0 +1,94 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Security.Jwt; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Identity.Features.GeneratingJwtToken.v1; + +internal record GenerateJwtToken(ApplicationUser User, string RefreshToken) : ICommand +{ + /// + /// GenerateJwtToken with in-line validation. + /// + /// + /// + /// + public static GenerateJwtToken Of(ApplicationUser? user, string? refreshToken) + { + user.NotBeNull(); + refreshToken.NotBeNull(); + + return new GenerateJwtToken(user, refreshToken); + } +} + +internal class GenerateJwtTokenHandler : ICommandHandler +{ + private readonly ILogger _logger; + private readonly UserManager _userManager; + private readonly IJwtService _jwtService; + + public GenerateJwtTokenHandler( + UserManager userManager, + IJwtService jwtService, + ILogger logger + ) + { + _userManager = userManager; + _jwtService = jwtService; + _logger = logger; + } + + public async Task Handle(GenerateJwtToken request, CancellationToken cancellationToken) + { + request.NotBeNull(); + + var identityUser = request.User; + identityUser.NotBeNull(); + + // authentication successful so generate jwt and refresh tokens + var allClaims = await GetClaimsAsync(request.User.UserName!); + var fullName = $"{identityUser.FirstName} {identityUser.LastName}"; + + var tokenResult = _jwtService.GenerateJwtToken( + identityUser.UserName!, + identityUser.Email!, + identityUser.Id.ToString(), + identityUser.EmailConfirmed || identityUser.PhoneNumberConfirmed, + fullName, + request.RefreshToken, + allClaims.UserClaims.ToImmutableList(), + allClaims.Roles.ToImmutableList(), + allClaims.PermissionClaims.ToImmutableList() + ); + + _logger.LogInformation("access-token generated, \n: {AccessToken}", tokenResult.AccessToken); + + return new GenerateJwtTokenResult(tokenResult.AccessToken, tokenResult.ExpireAt); + } + + private async Task<(IList UserClaims, IList Roles, IList PermissionClaims)> GetClaimsAsync( + string userName + ) + { + var appUser = await _userManager.FindByNameAsync(userName); + appUser.NotBeNull(); + + var userClaims = (await _userManager.GetClaimsAsync(appUser)) + .Where(x => x.Type != CustomClaimTypes.Permission) + .ToList(); + var roles = await _userManager.GetRolesAsync(appUser); + + var permissions = (await _userManager.GetClaimsAsync(appUser)) + .Where(x => x.Type == CustomClaimTypes.Permission) + ?.Select(x => x.Value) + .ToList(); + + return (UserClaims: userClaims, Roles: roles, PermissionClaims: permissions); + } +} + +public record GenerateJwtTokenResult(string Token, DateTime ExpireAt); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingRefreshToken/v1/GenerateRefreshToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingRefreshToken/v1/GenerateRefreshToken.cs new file mode 100644 index 00000000..f375b5b2 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GeneratingRefreshToken/v1/GenerateRefreshToken.cs @@ -0,0 +1,100 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Utils; +using FoodDelivery.Services.Identity.Identity.Dtos.v1; +using FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; +using FoodDelivery.Services.Identity.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.GeneratingRefreshToken.v1; + +internal record GenerateRefreshToken(Guid UserId, string? Token = null) : ICommand +{ + public static GenerateRefreshToken Of(Guid userId, string? token = null) => new(userId.NotBeEmpty(), token); +} + +internal class GenerateRefreshTokenHandler : ICommandHandler +{ + private readonly IdentityContext _context; + + public GenerateRefreshTokenHandler(IdentityContext context) + { + _context = context; + } + + public async Task Handle( + GenerateRefreshToken command, + CancellationToken cancellationToken + ) + { + command.NotBeNull(); + + var refreshToken = await _context + .Set() + .FirstOrDefaultAsync(rt => rt.UserId == command.UserId && rt.Token == command.Token, cancellationToken); + + if (refreshToken == null) + { + var token = Shared.Models.RefreshToken.GetRefreshToken(); + + refreshToken = new Shared.Models.RefreshToken + { + UserId = command.UserId, + Token = token, + CreatedAt = DateTime.Now, + ExpiredAt = DateTime.Now.AddDays(1), + CreatedByIp = IpUtilities.GetIpAddress() + }; + + await _context.Set().AddAsync(refreshToken, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + else + { + if (!refreshToken.IsRefreshTokenValid()) + throw new InvalidRefreshTokenException(refreshToken); + + var token = Shared.Models.RefreshToken.GetRefreshToken(); + + refreshToken.Token = token; + refreshToken.ExpiredAt = DateTime.Now; + refreshToken.CreatedAt = DateTime.Now.AddDays(10); + refreshToken.CreatedByIp = IpUtilities.GetIpAddress(); + + _context.Set().Update(refreshToken); + await _context.SaveChangesAsync(cancellationToken); + } + + // remove old refresh tokens from user + // we could also maintain them on the database with changing their revoke date + await RemoveOldRefreshTokens(command.UserId); + + return new GenerateRefreshTokenResult( + new RefreshTokenDto + { + Token = refreshToken.Token, + CreatedAt = refreshToken.CreatedAt, + ExpireAt = refreshToken.ExpiredAt, + UserId = refreshToken.UserId, + CreatedByIp = refreshToken.CreatedByIp, + IsActive = refreshToken.IsActive, + IsExpired = refreshToken.IsExpired, + IsRevoked = refreshToken.IsRevoked, + RevokedAt = refreshToken.RevokedAt + } + ); + } + + private Task RemoveOldRefreshTokens(Guid userId, long? ttlRefreshToken = null) + { + var refreshTokens = _context + .Set() + .Where(rt => rt.UserId == userId); + + refreshTokens.ToList().RemoveAll(x => !x.IsRefreshTokenValid(ttlRefreshToken)); + + return _context.SaveChangesAsync(); + } +} + +public record GenerateRefreshTokenResult(RefreshTokenDto RefreshToken); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/ClaimDto.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/ClaimDto.cs new file mode 100644 index 00000000..94344ef9 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/ClaimDto.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Identity.Identity.Features.GettingClaims.v1; + +public class ClaimDto +{ + public required string Type { get; init; } + public required string Value { get; init; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaims.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaims.cs new file mode 100644 index 00000000..9091334f --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaims.cs @@ -0,0 +1,29 @@ +using BuildingBlocks.Abstractions.CQRS.Queries; + +namespace FoodDelivery.Services.Identity.Identity.Features.GettingClaims.v1; + +internal record GetClaims : IQuery +{ + public static GetClaims Of() => new(); +} + +internal class GetClaimsQueryHandler : IQueryHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public GetClaimsQueryHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Task Handle(GetClaims request, CancellationToken cancellationToken) + { + var claims = _httpContextAccessor.HttpContext?.User.Claims.Select( + x => new ClaimDto { Type = x.Type, Value = x.Value } + ); + + return Task.FromResult(new GetClaimsResult(claims)); + } +} + +internal record GetClaimsResult(IEnumerable? Claims); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaimsEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaimsEndpoint.cs new file mode 100644 index 00000000..aa8de0b6 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingClaims/v1/GetClaimsEndpoint.cs @@ -0,0 +1,52 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.GettingClaims.v1; + +internal static class GetClaimsEndpoint +{ + internal static RouteHandlerBuilder MapGetClaimsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/claims", Handle) + .RequireAuthorization() + .WithTags(IdentityConfigs.Tag) + .WithName(nameof(GetClaims)) + .WithSummaryAndDescription(nameof(GetClaims).Humanize(), nameof(GetClaims).Humanize()) + .WithDisplayName(nameof(GetClaims).Humanize()) + .MapToApiVersion(1.0); + // // Api Documentations will produce automatically by typed result in minimal apis. + // // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(statusCode: StatusCodes.Status200OK) + // .ProducesProblem(StatusCodes.Status401Unauthorized); + + async Task, ValidationProblem, UnAuthorizedHttpProblemResult>> Handle( + [AsParameters] GetClaimsRequestParameters requestParameters + ) + { + var (context, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetClaims.Of(), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetClaimsResponse(result.Claims)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetClaimsRequestParameters( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +internal record GetClaimsResponse(IEnumerable? Claims); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingRefreshTokenValidity/v1/GetRefreshTokenValidity.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingRefreshTokenValidity/v1/GetRefreshTokenValidity.cs new file mode 100644 index 00000000..e2c67ac4 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/GettingRefreshTokenValidity/v1/GetRefreshTokenValidity.cs @@ -0,0 +1,39 @@ +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Identity.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.GettingRefreshTokenValidity.v1; + +internal record GetRefreshTokenValidity(Guid UserId, string RefreshToken) : IQuery +{ + public static GetRefreshTokenValidity Of(Guid userId, string? refreshToken) => + new(userId.NotBeEmpty(), refreshToken.NotBeNull()); +} + +internal class GetRefreshTokenValidityQueryHandler : IQueryHandler +{ + private readonly IdentityContext _context; + + public GetRefreshTokenValidityQueryHandler(IdentityContext context) + { + _context = context; + } + + public async Task Handle(GetRefreshTokenValidity request, CancellationToken cancellationToken) + { + var refreshToken = await _context + .Set() + .FirstOrDefaultAsync( + rt => rt.UserId == request.UserId && rt.Token == request.RefreshToken, + cancellationToken + ); + + if (refreshToken == null) + { + return false; + } + + return refreshToken.IsRefreshTokenValid(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/Login.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/Login.cs new file mode 100644 index 00000000..13fcb0f4 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/Login.cs @@ -0,0 +1,162 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Utils; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Identity.Exceptions; +using FoodDelivery.Services.Identity.Identity.Features.GeneratingJwtToken.v1; +using FoodDelivery.Services.Identity.Identity.Features.GeneratingRefreshToken.v1; +using FoodDelivery.Services.Identity.Shared.Data; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Identity.Identity.Features.Login.v1; + +internal record Login(string UserNameOrEmail, string Password, bool Remember) : ICommand, ITxRequest +{ + /// + /// Login with in-line validator + /// + /// + /// + /// + /// + public static Login Of(string? userNameOrEmail, string? password, bool rememberMe) + { + return new LoginValidator().HandleValidation(new Login(userNameOrEmail!, password!, rememberMe)); + } +} + +internal class LoginValidator : AbstractValidator +{ + public LoginValidator() + { + RuleFor(x => x.UserNameOrEmail).NotEmpty().WithMessage("UserNameOrEmail cannot be empty"); + RuleFor(x => x.Password).NotEmpty().WithMessage("password cannot be empty"); + } +} + +internal class LoginHandler : ICommandHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly IJwtService _jwtService; + private readonly JwtOptions _jwtOptions; + private readonly ILogger _logger; + private readonly IQueryProcessor _queryProcessor; + private readonly SignInManager _signInManager; + private readonly IdentityContext _context; + private readonly UserManager _userManager; + + public LoginHandler( + UserManager userManager, + ICommandProcessor commandProcessor, + IQueryProcessor queryProcessor, + IJwtService jwtService, + IOptions jwtOptions, + SignInManager signInManager, + IdentityContext context, + ILogger logger + ) + { + _userManager = userManager; + _commandProcessor = commandProcessor; + _queryProcessor = queryProcessor; + _jwtService = jwtService; + _signInManager = signInManager; + _context = context; + _jwtOptions = jwtOptions.Value; + _logger = logger; + } + + public async Task Handle(Login command, CancellationToken cancellationToken) + { + command.NotBeNull(); + var identityUser = + await _userManager.FindByNameAsync(command.UserNameOrEmail) + ?? await _userManager.FindByEmailAsync(command.UserNameOrEmail); + + identityUser.NotBeNull(exception: new IdentityUserNotFoundException(command.UserNameOrEmail)); + + // instead of PasswordSignInAsync, we use CheckPasswordSignInAsync because we don't want set cookie, instead we use JWT + var signinResult = await _signInManager.CheckPasswordSignInAsync(identityUser, command.Password, false); + + if (signinResult.IsNotAllowed) + { + if (!await _userManager.IsEmailConfirmedAsync(identityUser)) + throw new EmailNotConfirmedException(identityUser.Email!); + + if (!await _userManager.IsPhoneNumberConfirmedAsync(identityUser)) + throw new PhoneNumberNotConfirmedException(identityUser.PhoneNumber!); + } + else if (signinResult.IsLockedOut) + { + throw new UserLockedException(identityUser.Id.ToString()); + } + else if (signinResult.RequiresTwoFactor) + { + throw new RequiresTwoFactorException("Require two factor authentication."); + } + else if (!signinResult.Succeeded) + { + throw new PasswordIsInvalidException("Password is invalid."); + } + + var refreshToken = ( + await _commandProcessor.SendAsync(GenerateRefreshToken.Of(identityUser.Id), cancellationToken) + ).RefreshToken; + + var accessToken = await _commandProcessor.SendAsync( + GenerateJwtToken.Of(identityUser, refreshToken.Token), + cancellationToken + ); + + if (string.IsNullOrWhiteSpace(accessToken.Token)) + throw new AppException("Generate access token failed."); + + _logger.LogInformation("User with ID: {ID} has been authenticated", identityUser.Id); + + if (_jwtOptions.CheckRevokedAccessTokens) + { + await _context + .Set() + .AddAsync( + new AccessToken + { + UserId = identityUser.Id, + Token = accessToken.Token, + CreatedAt = DateTime.Now, + ExpiredAt = accessToken.ExpireAt, + CreatedByIp = IpUtilities.GetIpAddress() + }, + cancellationToken + ); + + await _context.SaveChangesAsync(cancellationToken); + } + + // we can don't return value from command and get token from a short term session in our request with `TokenStorageService` + return new LoginResult( + identityUser.Id, + identityUser.UserName!, + identityUser.FirstName, + identityUser.LastName, + accessToken.Token, + refreshToken.Token + ); + } +} + +internal record LoginResult( + Guid UserId, + string UserName, + string FirstName, + string LastName, + string AccessToken, + string RefreshToken +); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginEndpoint.cs new file mode 100644 index 00000000..6e4e63c6 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginEndpoint.cs @@ -0,0 +1,70 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.Login.v1; + +internal static class LoginEndpoint +{ + internal static RouteHandlerBuilder MapLoginUserEndpoint(this IEndpointRouteBuilder endpoints) + { + // https://github.com/dotnet/aspnetcore/issues/45082 + // https://github.com/dotnet/aspnetcore/issues/40753 + // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/2414 + return endpoints + .MapPost("/login", Handle) + .AllowAnonymous() + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status200OK) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesProblem(StatusCodes.Status500InternalServerError) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(Login)) + .WithDisplayName(nameof(Login).Humanize()) + .WithSummaryAndDescription(nameof(Login).Humanize(), nameof(Login).Humanize()); + + async Task< + Results, InternalHttpProblemResult, ForbiddenHttpProblemResult, ValidationProblem> + > Handle([AsParameters] LoginRequestParameters requestParameters) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = Login.Of(request.UserNameOrEmail, request.Password, request.Remember); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + var response = mapper.Map(result); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(response); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record LoginRequestParameters( + [FromBody] LoginRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +// These parameters can be pass null from the user +internal record LoginRequest(string? UserNameOrEmail, string? Password, bool Remember); + +internal record LoginResponse( + Guid UserId, + string UserName, + string FirstName, + string LastName, + string AccessToken, + string RefreshToken +); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginFailedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginFailedException.cs new file mode 100644 index 00000000..7bd57347 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Login/v1/LoginFailedException.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Features.Login.v1; + +public class LoginFailedException : AppException +{ + public LoginFailedException(string userNameOrEmail) + : base($"Login failed for username: {userNameOrEmail}") + { + UserNameOrEmail = userNameOrEmail; + } + + public string UserNameOrEmail { get; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Logout/v1/LogoutEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Logout/v1/LogoutEndpoint.cs new file mode 100644 index 00000000..a2522a76 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/Logout/v1/LogoutEndpoint.cs @@ -0,0 +1,71 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Caching; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using EasyCaching.Core; +using Humanizer; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Identity.Identity.Features.Logout.v1; + +public static class LogoutEndpoint +{ + internal static RouteHandlerBuilder MapLogoutEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/logout", Handle) + .RequireAuthorization() + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + //.Produces(StatusCodes.Status200OK) + .WithName("Logout") + .WithDisplayName("Logout".Humanize()) + .WithSummaryAndDescription("Logout".Humanize(), "Logout".Humanize()) + .MapToApiVersion(1.0); + + async Task Handle([AsParameters] LogoutRequestParameters requestParameters) + { + var (context, commandProcessor, mapper, caching, jwtOptions, cancellationToken) = requestParameters; + var cacheProvider = caching.GetCachingProvider(nameof(CacheProviderType.InMemory)); + + await context.SignOutAsync(); + + if (jwtOptions.Value.CheckRevokedAccessTokens) + { + // https://dev.to/chukwutosin_/how-to-invalidate-a-jwt-using-a-blacklist-28dl + // https://supertokens.com/blog/revoking-access-with-a-jwt-blacklist + // The blacklist is saved in the format => "userName_revoked_tokens": [token1, token2,...] + var token = GetTokenFromHeader(context); + var userName = context.User.Identity!.Name; + await cacheProvider.SetAsync( + $"{userName}_{token}_revoked_token", + token, + TimeSpan.FromSeconds(jwtOptions.Value.TokenLifeTimeSecond) + ); + } + + return TypedResults.Ok(); + } + } + + private static string GetTokenFromHeader(HttpContext context) + { + var authorizationHeader = context.Request.Headers.Get("authorization"); + return authorizationHeader; + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record LogoutRequestParameters( + [FromBody] HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + IEasyCachingProviderFactory CachingProviderFactory, + IOptions JwtOptions, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/InvalidRefreshTokenException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/InvalidRefreshTokenException.cs new file mode 100644 index 00000000..da256a32 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/InvalidRefreshTokenException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; + +public class InvalidRefreshTokenException : BadRequestException +{ + public InvalidRefreshTokenException(Shared.Models.RefreshToken? refreshToken) + : base($"refresh token {refreshToken?.Token} is invalid!") { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshToken.cs new file mode 100644 index 00000000..9eb85b81 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshToken.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Identity.Exceptions; +using FoodDelivery.Services.Identity.Identity.Features.GeneratingJwtToken.v1; +using FoodDelivery.Services.Identity.Identity.Features.GeneratingRefreshToken.v1; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; + +namespace FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; + +internal record RefreshToken(string AccessTokenData, string RefreshTokenData) : ICommand +{ + /// + /// RefreshToken with in-line validation. + /// + /// + /// + /// + public static RefreshToken Of(string? accessTokenData, string? refreshTokenData) + { + return new RefreshTokenValidator().HandleValidation(new RefreshToken(accessTokenData!, refreshTokenData!)); + } +} + +internal class RefreshTokenValidator : AbstractValidator +{ + public RefreshTokenValidator() + { + RuleFor(v => v.AccessTokenData).NotEmpty(); + RuleFor(v => v.RefreshTokenData).NotEmpty(); + } +} + +internal class RefreshTokenHandler : ICommandHandler +{ + private readonly ICommandProcessor _commandProcessor; + private readonly IJwtService _jwtService; + private readonly UserManager _userManager; + + public RefreshTokenHandler( + IJwtService jwtService, + UserManager userManager, + ICommandProcessor commandProcessor + ) + { + _jwtService = jwtService; + _userManager = userManager; + _commandProcessor = commandProcessor; + } + + public async Task Handle(RefreshToken command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + // invalid token/signing key was passed and we can't extract user claims + var userClaimsPrincipal = _jwtService.GetPrincipalFromToken(command.AccessTokenData); + + if (userClaimsPrincipal is null) + throw new InvalidTokenException(userClaimsPrincipal); + + var userId = userClaimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.NameId); + + var identityUser = await _userManager.FindByIdAsync(userId); + + if (identityUser == null) + throw new IdentityUserNotFoundException(userId); + + var refreshToken = ( + await _commandProcessor.SendAsync( + GenerateRefreshToken.Of(identityUser.Id, command.RefreshTokenData), + cancellationToken + ) + ).RefreshToken; + + var accessToken = await _commandProcessor.SendAsync( + GenerateJwtToken.Of(identityUser, refreshToken.Token), + cancellationToken + ); + + return new RefreshTokenResult( + identityUser.Id, + identityUser.UserName!, + identityUser.FirstName, + identityUser.LastName, + accessToken.Token, + refreshToken.Token + ); + } +} + +internal record RefreshTokenResult( + Guid UserId, + string UserName, + string FirstName, + string LastName, + string AccessToken, + string RefreshToken +); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshTokenEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshTokenEndpoint.cs new file mode 100644 index 00000000..8e0b1c65 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RefreshingToken/v1/RefreshTokenEndpoint.cs @@ -0,0 +1,65 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; + +public static class RefreshTokenEndpoint +{ + internal static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/refresh-token", Handle) + .RequireAuthorization() + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status200OK)) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(RefreshToken)) + .WithDisplayName(nameof(RefreshToken).Humanize()) + .WithSummaryAndDescription(nameof(RefreshToken).Humanize(), nameof(RefreshToken).Humanize()); + + async Task, NotFoundHttpProblemResult, ValidationProblem>> Handle( + [AsParameters] RefreshTokenRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = RefreshToken.Of(request.AccessToken, request.RefreshToken); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + var response = mapper.Map(result); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(response); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record RefreshTokenRequestParameters( + [FromBody] RefreshTokenRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +// These parameters can be pass null from the user +public record RefreshTokenRequest(string? AccessToken, string? RefreshToken); + +internal record RefreshTokenResponse( + Guid UserId, + string UserName, + string FirstName, + string LastName, + string AccessToken, + string RefreshToken +); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/ResettingPassword/v1/ResetPassword.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/ResettingPassword/v1/ResetPassword.cs new file mode 100644 index 00000000..d6358377 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/ResettingPassword/v1/ResetPassword.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Identity.Identity.Features.ResettingPassword.v1; + +public record ResetPassword : ICommand { } diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessToken.cs new file mode 100644 index 00000000..9d7f6182 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessToken.cs @@ -0,0 +1,59 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Caching; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Security.Jwt; +using EasyCaching.Core; +using Microsoft.Extensions.Options; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingAccessToken.v1; + +internal record RevokeAccessToken(string Token, string UserName) : ICommand +{ + /// + /// RevokeAccessToken with in-line validation. + /// + /// + /// + /// + public static RevokeAccessToken Of(string? token, string? userName) + { + token.NotBeNullOrWhiteSpace(); + userName.NotBeNullOrWhiteSpace(); + + return new RevokeAccessToken(token, userName); + } +} + +internal class RevokeAccessTokenHandler : ICommandHandler +{ + private readonly IEasyCachingProvider _cachingProvider; + private readonly JwtOptions _jwtOptions; + + public RevokeAccessTokenHandler( + IEasyCachingProviderFactory cachingProviderFactory, + IOptions jwtOptions, + IOptions cacheOptions + ) + { + _cachingProvider = cachingProviderFactory.GetCachingProvider(cacheOptions.Value.DefaultCacheType); + _jwtOptions = jwtOptions.Value; + } + + public async Task Handle(RevokeAccessToken command, CancellationToken cancellationToken) + { + command.NotBeNull(); + command.Token.NotBeNullOrWhiteSpace(); + + // https://dev.to/chukwutosin_/how-to-invalidate-a-jwt-using-a-blacklist-28dl + // https://supertokens.com/blog/revoking-access-with-a-jwt-blacklist + // The blacklist is saved in the format => "userName_revoked_tokens": [token1, token2,...] + await _cachingProvider.SetAsync( + $"{command.UserName}_{command.Token}_revoked_token", + command.Token, + TimeSpan.FromSeconds(_jwtOptions.TokenLifeTimeSecond), + cancellationToken + ); + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessTokenEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessTokenEndpoint.cs new file mode 100644 index 00000000..a81b348e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAccessToken/v1/RevokeAccessTokenEndpoint.cs @@ -0,0 +1,61 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingAccessToken.v1; + +public static class RevokeAccessTokenEndpoint +{ + public static RouteHandlerBuilder MapRevokeAccessTokenEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/revoke-token", Handle) + .RequireAuthorization(IdentityConstants.Role.User) + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status204NoContent) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(RevokeAccessToken)) + .WithDisplayName(nameof(RevokeAccessToken).Humanize()) + .WithSummaryAndDescription(nameof(RevokeAccessToken).Humanize(), nameof(RevokeAccessToken).Humanize()); + + async Task> Handle( + [AsParameters] RevokeAccessTokenRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + var token = string.IsNullOrWhiteSpace(request.AccessToken) + ? GetTokenFromHeader(context) + : request.AccessToken; + + var command = RevokeAccessToken.Of(token, context.User.Identity!.Name!); + await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.NoContent(); + } + } + + private static string GetTokenFromHeader(HttpContext context) + { + var authorizationHeader = context.Request.Headers.Get("authorization"); + return authorizationHeader; + } +} + +public record RevokeAccessTokenRequest(string? AccessToken); + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record RevokeAccessTokenRequestParameters( + [FromBody] RevokeAccessTokenRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokens.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokens.cs new file mode 100644 index 00000000..e1ce4168 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokens.cs @@ -0,0 +1,54 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Identity.Identity.Features.RevokingAccessToken.v1; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingAllAccessTokens.v1; + +internal record RevokeAllAccessTokens(string UserName) : ICommand +{ + /// + /// RevokeAllAccessTokens with in-line validation. + /// + /// + /// + public static RevokeAllAccessTokens Of(string? userName) => new(userName.NotBeEmptyOrNull()); +} + +internal class RevokeAllAccessTokenHandler : ICommandHandler +{ + private readonly IMediator _mediator; + private readonly UserManager _userManager; + private readonly IdentityDbContext _identityDbContext; + + public RevokeAllAccessTokenHandler( + IdentityDbContext identityDbContext, + IMediator mediator, + UserManager userManager + ) + { + _identityDbContext = identityDbContext; + _mediator = mediator; + _userManager = userManager; + } + + public async Task Handle(RevokeAllAccessTokens request, CancellationToken cancellationToken) + { + var appUser = await _userManager.FindByNameAsync(request.UserName); + appUser.NotBeNull(new IdentityUserNotFoundException(request.UserName)); + + var tokens = _identityDbContext + .Set() + .Where(x => x.UserId == appUser.Id && x.ExpiredAt > DateTime.Now); + + foreach (var accessToken in tokens) + { + await _mediator.Send(new RevokeAccessToken(accessToken.Token, appUser.UserName!), cancellationToken); + } + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokensEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokensEndpoint.cs new file mode 100644 index 00000000..045da257 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingAllAccessTokens/v1/RevokeAllAccessTokensEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingAllAccessTokens.v1; + +public static class RevokeAllAccessTokensEndpoint +{ + public static IEndpointRouteBuilder MapRevokeAllAccessTokensEndpoint(this IEndpointRouteBuilder endpoints) + { + endpoints + .MapPost("/revoke-all-tokens", Handle) + .RequireAuthorization(IdentityConstants.Role.User) + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .Produces(StatusCodes.Status204NoContent) + .WithName(nameof(RevokeAllAccessTokens)) + .WithDisplayName(nameof(RevokeAllAccessTokens).Humanize()) + .WithSummaryAndDescription( + nameof(RevokeAllAccessTokens).Humanize(), + nameof(RevokeAllAccessTokens).Humanize() + ); + + return endpoints; + + async Task> Handle( + [AsParameters] RevokeAllTokensRequestParameters requestParameters + ) + { + var (context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = RevokeAllAccessTokens.Of(context.User.Identity!.Name!); + + await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.NoContent(); + } + } +} + +internal record RevokeAllTokensRequestParameters( + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshToken.cs new file mode 100644 index 00000000..f6fde8ec --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshToken.cs @@ -0,0 +1,44 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Identity.Identity.Exceptions; +using FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; +using FoodDelivery.Services.Identity.Shared.Data; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingRefreshToken.v1; + +internal record RevokeRefreshToken(string RefreshToken) : ICommand +{ + public static RevokeRefreshToken Of(string? refreshToken) => new(refreshToken.NotBeEmptyOrNull()); +} + +internal class RevokeRefreshTokenHandler : ICommandHandler +{ + private readonly IdentityContext _context; + + public RevokeRefreshTokenHandler(IdentityContext context) + { + _context = context; + } + + public async Task Handle(RevokeRefreshToken command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var refreshToken = await _context + .Set() + .FirstOrDefaultAsync(x => x.Token == command.RefreshToken, cancellationToken: cancellationToken); + + if (refreshToken == null) + throw new RefreshTokenNotFoundException(refreshToken); + + if (!refreshToken.IsRefreshTokenValid()) + throw new InvalidRefreshTokenException(refreshToken); + + // revoke token and save + refreshToken.RevokedAt = DateTime.Now; + await _context.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshTokenEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshTokenEndpoint.cs new file mode 100644 index 00000000..d1347b1d --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/RevokingRefreshToken/v1/RevokeRefreshTokenEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.RevokingRefreshToken.v1; + +public static class RevokeRefreshTokenEndpoint +{ + internal static IEndpointRouteBuilder MapRevokeTokenEndpoint(this IEndpointRouteBuilder endpoints) + { + endpoints + .MapPost("/revoke-refresh-token", Handle) + .RequireAuthorization() + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status204NoContent) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(RevokeRefreshToken)) + .WithDisplayName(nameof(RevokeRefreshToken).Humanize()) + .WithSummaryAndDescription(nameof(RevokeRefreshToken).Humanize(), nameof(RevokeRefreshToken).Humanize()); + + return endpoints; + + async Task> Handle( + [AsParameters] RevokeRefreshTokenRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = RevokeRefreshToken.Of(request.RefreshToken); + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } + } +} + +public record RevokeRefreshTokenRequest(string? RefreshToken); + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record RevokeRefreshTokenRequestParameters( + [FromBody] RevokeRefreshTokenRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCode.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCode.cs new file mode 100644 index 00000000..d5738639 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCode.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Security.Cryptography; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Email; +using FoodDelivery.Services.Identity.Shared.Data; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.SendingEmailVerificationCode.v1; + +internal record SendEmailVerificationCode(string Email) : ICommand +{ + public static SendEmailVerificationCode Of(string? email) => new(email.NotBeEmptyOrNull()); +} + +internal class SendEmailVerificationCodeCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IdentityContext _context; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; + + public SendEmailVerificationCodeCommandHandler( + UserManager userManager, + IdentityContext context, + IEmailSender emailSender, + ILogger logger + ) + { + _userManager = userManager; + _context = context; + _emailSender = emailSender; + _logger = logger; + } + + public async Task Handle(SendEmailVerificationCode command, CancellationToken cancellationToken) + { + command.NotBeNull(); + var identityUser = await _userManager.FindByEmailAsync(command.Email); + + identityUser.NotBeNull(new IdentityUserNotFoundException(command.Email)); + + if (identityUser.EmailConfirmed) + throw new ConflictException("Email is already confirmed."); + + bool isExists = await _context + .Set() + .AnyAsync(evc => evc.Email == command.Email && evc.SentAt.AddMinutes(5) > DateTime.Now, cancellationToken); + + if (isExists) + { + throw new BadRequestException( + "You already have an active code. Please wait! You may receive the code in your email. If not, please try again after sometimes." + ); + } + + int randomNumber = RandomNumberGenerator.GetInt32(0, 1000000); + string verificationCode = randomNumber.ToString("D6", CultureInfo.InvariantCulture); + + EmailVerificationCode emailVerificationCode = new EmailVerificationCode() + { + Code = verificationCode, + Email = command.Email, + SentAt = DateTime.Now + }; + + await _context.Set().AddAsync(emailVerificationCode, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + (string Email, string VerificationCode) model = (command.Email, verificationCode); + + string content = + $"Welcome to shop application! Please verify your email with using this Code: {model.VerificationCode}."; + + string subject = "Verification Email"; + + EmailObject emailObject = new EmailObject(command.Email, subject, content); + + await _emailSender.SendAsync(emailObject); + + _logger.LogInformation("Verification email sent successfully for userId:{UserId}", identityUser.Id); + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCodeEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCodeEndpoint.cs new file mode 100644 index 00000000..431273d8 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingEmailVerificationCode/v1/SendEmailVerificationCodeEndpoint.cs @@ -0,0 +1,54 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.SendingEmailVerificationCode.v1; + +public static class SendEmailVerificationCodeEndpoint +{ + internal static RouteHandlerBuilder MapSendEmailVerificationCodeEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/send-email-verification-code", Handle) + .AllowAnonymous() + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status204NoContent) + // .ProducesProblem(StatusCodes.Status409Conflict) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(SendEmailVerificationCode)) + .WithDisplayName(nameof(SendEmailVerificationCode).Humanize()) + .WithSummaryAndDescription( + nameof(SendEmailVerificationCode).Humanize(), + nameof(SendEmailVerificationCode).Humanize() + ); + + async Task> Handle( + [AsParameters] SendEmailVerificationCodeRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + var command = SendEmailVerificationCode.Of(request.Email); + + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } + } +} + +public record SendEmailVerificationCodeRequest(string? Email); + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record SendEmailVerificationCodeRequestParameters( + [FromBody] SendEmailVerificationCodeRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingResetPasswordCode/v1/SendResetPasswordCode.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingResetPasswordCode/v1/SendResetPasswordCode.cs new file mode 100644 index 00000000..a0e343a5 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/SendingResetPasswordCode/v1/SendResetPasswordCode.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; + +namespace FoodDelivery.Services.Identity.Identity.Features.SendingResetPasswordCode.v1; + +public record SendResetPasswordCode : ICommand { } diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/EmailAlreadyVerifiedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/EmailAlreadyVerifiedException.cs new file mode 100644 index 00000000..c95591d7 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/EmailAlreadyVerifiedException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1.Exceptions; + +public class EmailAlreadyVerifiedException : ConflictException +{ + public EmailAlreadyVerifiedException(string email) + : base($"User with email {email} already verified.") { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/VerificationTokenIsInvalidException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/VerificationTokenIsInvalidException.cs new file mode 100644 index 00000000..da05394e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/Exceptions/VerificationTokenIsInvalidException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1.Exceptions; + +public class VerificationTokenIsInvalidException : BadRequestException +{ + public VerificationTokenIsInvalidException(string userId) + : base($"verification token is invalid for userId '{userId}'.") { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmail.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmail.cs new file mode 100644 index 00000000..e78a20f6 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmail.cs @@ -0,0 +1,79 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1.Exceptions; +using FoodDelivery.Services.Identity.Shared.Data; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1; + +public record VerifyEmail(string Email, string Code) : ICommand +{ + /// + /// Verify email with in-line validation. + /// + /// + /// + /// + public static VerifyEmail Of(string? email, string? code) => + new(email.NotBeEmptyOrNull().NotBeInvalidEmail(), code.NotBeEmptyOrNull()); +} + +internal class VerifyEmailHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IdentityContext _dbContext; + private readonly ILogger _logger; + + public VerifyEmailHandler( + UserManager userManager, + IdentityContext dbContext, + ILogger logger + ) + { + _userManager = userManager; + _dbContext = dbContext; + _logger = logger; + } + + public async Task Handle(VerifyEmail command, CancellationToken cancellationToken) + { + command.NotBeNull(); + + var user = await _userManager.FindByEmailAsync(command.Email); + user.NotBeNull(new IdentityUserNotFoundException(command.Email)); + + if (user.EmailConfirmed) + { + throw new EmailAlreadyVerifiedException(user.Email!); + } + + var emailVerificationCode = await _dbContext + .Set() + .Where(x => x.Email == command.Email && x.Code == command.Code && x.UsedAt == null) + .SingleOrDefaultAsync(cancellationToken: cancellationToken); + + if (emailVerificationCode == null) + { + throw new BadRequestException("Either email or code is incorrect."); + } + + if (DateTime.Now > emailVerificationCode.SentAt.AddMinutes(5)) + { + throw new BadRequestException("The code is expired."); + } + + user.EmailConfirmed = true; + await _userManager.UpdateAsync(user); + + emailVerificationCode.UsedAt = DateTime.Now; + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Email verified successfully for userId:{UserId}", user.Id); + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmailEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmailEndpoint.cs new file mode 100644 index 00000000..8a927261 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Features/VerifyingEmail/v1/VerifyEmailEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1; + +public static class VerifyEmailEndpoint +{ + internal static RouteHandlerBuilder MapSendVerifyEmailEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/verify-email", Handle) + .AllowAnonymous() + .MapToApiVersion(1.0) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status204NoContent) + // .ProducesProblem(StatusCodes.Status409Conflict) + // .ProducesProblem(StatusCodes.Status500InternalServerError) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(VerifyEmail)) + .WithDisplayName(nameof(VerifyEmail).Humanize()) + .WithSummaryAndDescription(nameof(VerifyEmail).Humanize(), nameof(VerifyEmail).Humanize()); + + async Task> Handle( + [AsParameters] VerifyEmailRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = VerifyEmail.Of(request.Email, request.Code); + + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } + } +} + +internal record VerifyEmailRequest(string? Email, string? Code); + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record VerifyEmailRequestParameters( + [FromBody] VerifyEmailRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityConfigs.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityConfigs.cs new file mode 100644 index 00000000..ac51d394 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityConfigs.cs @@ -0,0 +1,94 @@ +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Identity.Identity.Data; +using FoodDelivery.Services.Identity.Identity.Features.GettingClaims.v1; +using FoodDelivery.Services.Identity.Identity.Features.Login.v1; +using FoodDelivery.Services.Identity.Identity.Features.Logout.v1; +using FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; +using FoodDelivery.Services.Identity.Identity.Features.RevokingAccessToken.v1; +using FoodDelivery.Services.Identity.Identity.Features.RevokingRefreshToken.v1; +using FoodDelivery.Services.Identity.Identity.Features.SendingEmailVerificationCode.v1; +using FoodDelivery.Services.Identity.Identity.Features.VerifyingEmail.v1; +using FoodDelivery.Services.Identity.Shared; +using FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Identity.Identity; + +internal class IdentityConfigs : IModuleConfiguration +{ + public const string Tag = "Identity"; + public const string IdentityPrefixUri = $"{SharedModulesConfiguration.IdentityModulePrefixUri}"; + + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + builder.AddCustomIdentity(builder.Configuration); + + builder.Services.TryAddScoped(); + + if (builder.Environment.IsTest() == false) + builder.AddCustomIdentityServer(); + + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + // https://github.com/dotnet/aspnet-api-versioning/commit/b789e7e980e83a7d2f82ce3b75235dee5e0724b4 + // changed from MapApiGroup to NewVersionedApi in v7.0.0 + var routeCategoryName = Tag; + var identityVersionGroup = endpoints.NewVersionedApi(name: routeCategoryName).WithTags(Tag); + + // create a new sub group for v1 version + var identityGroupV1 = identityVersionGroup + .MapGroup(IdentityPrefixUri) + .HasDeprecatedApiVersion(0.9) + .HasApiVersion(1.0); + + // create a new sub group for v2 version + var identityGroupV2 = identityVersionGroup.MapGroup(IdentityPrefixUri).HasApiVersion(2.0); + + identityGroupV1 + .MapGet( + "/user-role", + [Authorize( + AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, + Roles = IdentityConstants.Role.User + )] + () => new { Role = IdentityConstants.Role.User } + ) + .WithTags(Tag); + + identityGroupV1 + .MapGet( + "/admin-role", + [Authorize( + AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, + Roles = IdentityConstants.Role.Admin + )] + () => new { Role = IdentityConstants.Role.Admin } + ) + .WithTags(Tag); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-7.0#route-groups + // https://github.com/dotnet/aspnet-api-versioning/blob/main/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs + identityGroupV1.MapLoginUserEndpoint(); + identityGroupV1.MapLogoutEndpoint(); + identityGroupV1.MapSendEmailVerificationCodeEndpoint(); + identityGroupV1.MapSendVerifyEmailEndpoint(); + identityGroupV1.MapRefreshTokenEndpoint(); + identityGroupV1.MapRevokeTokenEndpoint(); + identityGroupV1.MapRevokeAccessTokenEndpoint(); + identityGroupV1.MapGetClaimsEndpoint(); + + return endpoints; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityMapping.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityMapping.cs new file mode 100644 index 00000000..4dd8b85a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityMapping.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using FoodDelivery.Services.Identity.Identity.Features.Login.v1; +using FoodDelivery.Services.Identity.Identity.Features.RefreshingToken.v1; + +namespace FoodDelivery.Services.Identity.Identity; + +public class IdentityMapping : Profile +{ + public IdentityMapping() + { + CreateMap(); + CreateMap(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityServerConfig.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityServerConfig.cs new file mode 100644 index 00000000..5d3be91e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/IdentityServerConfig.cs @@ -0,0 +1,70 @@ +using Duende.IdentityServer; +using Duende.IdentityServer.Models; +using IdentityModel; + +namespace FoodDelivery.Services.Identity.Identity; + +// Ref: https://docs.duendesoftware.com/identityserver/v5/fundamentals/resources/api_resources/ +// https://docs.duendesoftware.com/identityserver/v5/fundamentals/resources/identity/ +// https://docs.duendesoftware.com/identityserver/v5/fundamentals/resources/api_scopes/ +public static class IdentityServerConfig +{ + public static IEnumerable IdentityResources => + new List + { + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email(), + new IdentityResources.Phone(), + new IdentityResources.Address(), + new("roles", "User Roles", new List { "role" }) + }; + + public static IEnumerable ApiScopes => + new List { new("food-delivery-api", "FoodDelivery.Services.Catalogs Web API") }; + + public static IList ApiResources => + new List + { + new ApiResource("ShopApiResource", "FoodDelivery.Services.Catalogs Web API Resource") + { + Scopes = { "food-delivery-api" }, + UserClaims = { JwtClaimTypes.Role, JwtClaimTypes.Name, JwtClaimTypes.Id } + } + }; + + public static IEnumerable Clients => + new List + { + new() + { + ClientId = "frontend-client", + ClientName = "Frontend Client", + RequireClientSecret = false, + AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, + AllowedScopes = + { + IdentityServerConstants.StandardScopes.OpenId, + IdentityServerConstants.StandardScopes.Profile, + IdentityServerConstants.StandardScopes.Email, + "roles", + "food-delivery-api" + } + }, + new() + { + ClientId = "oauthClient", + ClientName = "Example client application using client credentials", + AllowedGrantTypes = GrantTypes.ClientCredentials, + ClientSecrets = new List { new("SuperSecretPassword".Sha256()) }, // change me! + AllowedScopes = + { + IdentityServerConstants.StandardScopes.OpenId, + IdentityServerConstants.StandardScopes.Profile, + IdentityServerConstants.StandardScopes.Email, + "roles", + "food-delivery-api" + } + } + }; +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Services/IdentityProfileService.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Services/IdentityProfileService.cs new file mode 100644 index 00000000..bbca3cef --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Identity/Services/IdentityProfileService.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using FoodDelivery.Services.Identity.Shared.Models; +using IdentityModel; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Identity.Services; + +public class IdentityProfileService : IProfileService +{ + private readonly IUserClaimsPrincipalFactory _claimsFactory; + private readonly UserManager _userManager; + + public IdentityProfileService( + IUserClaimsPrincipalFactory claimsFactory, + UserManager userManager + ) + { + _claimsFactory = claimsFactory; + _userManager = userManager; + } + + public async Task GetProfileDataAsync(ProfileDataRequestContext context) + { + var sub = context.Subject.GetSubjectId(); + var user = await _userManager.FindByIdAsync(sub); + var roles = await _userManager.GetRolesAsync(user); + var isAdmin = roles.Contains(IdentityConstants.Role.Admin); + var principal = await _claimsFactory.CreateAsync(user); + + var claims = principal.Claims.ToList(); + claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList(); + + claims.Add(new Claim(JwtClaimTypes.Id, user.Id.ToString())); + claims.Add(new Claim(JwtClaimTypes.Name, user.UserName)); + claims.Add(new Claim(JwtClaimTypes.Email, user.Email)); + + claims.Add(isAdmin ? new Claim(JwtClaimTypes.Role, "admin") : new Claim(JwtClaimTypes.Role, "user")); + + context.IssuedClaims = claims; + } + + public async Task IsActiveAsync(IsActiveContext context) + { + var sub = context.Subject.GetSubjectId(); + var user = await _userManager.FindByIdAsync(sub); + context.IsActive = user != null; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/IdentityConstants.cs b/src/Services/Identity/FoodDelivery.Services.Identity/IdentityConstants.cs new file mode 100644 index 00000000..9d42f849 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/IdentityConstants.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Identity; + +public static class IdentityConstants +{ + public static class Role + { + public const string Admin = "admin"; + public const string User = "user"; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/IdentityMetadata.cs b/src/Services/Identity/FoodDelivery.Services.Identity/IdentityMetadata.cs new file mode 100644 index 00000000..8268b991 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/IdentityMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Identity; + +public class IdentityMetadata { } diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/DbContextDesignFactory.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/DbContextDesignFactory.cs new file mode 100644 index 00000000..5626f385 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/DbContextDesignFactory.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Persistence.EfCore.Postgres; + +namespace FoodDelivery.Services.Identity.Shared.Data; + +public class DbContextDesignFactory : DbContextDesignFactoryBase +{ + public DbContextDesignFactory() + : base("PostgresOptions:ConnectionString") { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityContext.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityContext.cs new file mode 100644 index 00000000..b01a9b74 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityContext.cs @@ -0,0 +1,123 @@ +using System.Data; +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence; +using FoodDelivery.Services.Identity.Shared.Models; +using Humanizer; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace FoodDelivery.Services.Identity.Shared.Data; + +public class IdentityContext + : IdentityDbContext< + ApplicationUser, + ApplicationRole, + Guid, + IdentityUserClaim, + ApplicationUserRole, + IdentityUserLogin, + IdentityRoleClaim, + IdentityUserToken + >, + IDbFacadeResolver, + IDomainEventContext, + ITxDbContextExecution +{ + public IdentityContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(GetType().Assembly); + + // https://andrewlock.net/customising-asp-net-core-identity-ef-core-naming-conventions-for-postgresql/ + foreach (var entity in builder.Model.GetEntityTypes()) + { + // Replace table names + entity.SetTableName(entity.GetTableName()?.Underscore()); + + var objectIdentifier = StoreObjectIdentifier.Table( + entity.GetTableName()?.Underscore()!, + entity.GetSchema() + ); + + // Replace column names + foreach (var property in entity.GetProperties()) + { + property.SetColumnName(property.GetColumnName(objectIdentifier)?.Underscore()); + } + + foreach (var key in entity.GetKeys()) + { + key.SetName(key.GetName()?.Underscore()); + } + + foreach (var key in entity.GetForeignKeys()) + { + key.SetConstraintName(key.GetConstraintName()?.Underscore()); + } + } + } + + public Task ExecuteTransactionalAsync(Func action, CancellationToken cancellationToken = default) + { + var strategy = Database.CreateExecutionStrategy(); + return strategy.ExecuteAsync(async () => + { + await using var transaction = await Database.BeginTransactionAsync( + IsolationLevel.ReadCommitted, + cancellationToken + ); + try + { + await action(); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + }); + } + + public Task ExecuteTransactionalAsync(Func> action, CancellationToken cancellationToken = default) + { + var strategy = Database.CreateExecutionStrategy(); + return strategy.ExecuteAsync(async () => + { + await using var transaction = await Database.BeginTransactionAsync( + IsolationLevel.ReadCommitted, + cancellationToken + ); + try + { + var result = await action(); + + await transaction.CommitAsync(cancellationToken); + + return result; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + }); + } + + public IReadOnlyList GetAllUncommittedEvents() + { + return new List(); + } + + public void MarkUncommittedDomainEventAsCommitted() + { + // Method intentionally left empty. + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityDataSeeder.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityDataSeeder.cs new file mode 100644 index 00000000..9fc30de1 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityDataSeeder.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Identity.Shared.Data; + +public class IdentityDataSeeder : IDataSeeder +{ + public int Order => 2; + + public Task SeedAllAsync() + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityMigrationExecutor.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityMigrationExecutor.cs new file mode 100644 index 00000000..a667c2b8 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/IdentityMigrationExecutor.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Abstractions.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Identity.Shared.Data; + +public class IdentityMigrationExecutor : IMigrationExecutor +{ + private readonly IdentityContext _identityContext; + private readonly ILogger _logger; + + public IdentityMigrationExecutor(IdentityContext identityContext, ILogger logger) + { + _identityContext = identityContext; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migration worker started"); + + _logger.LogInformation("Updating identity database..."); + + await _identityContext.Database.MigrateAsync(cancellationToken: cancellationToken); + + _logger.LogInformation("identity database Updated"); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.Designer.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.Designer.cs new file mode 100644 index 00000000..3f449e04 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.Designer.cs @@ -0,0 +1,568 @@ +// +using System; +using FoodDelivery.Services.Identity.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Identity.Shared.Data.Migrations.Identity +{ + [DbContext(typeof(IdentityContext))] + [Migration("20240719224959_InitialIdentityServerMigration")] + partial class InitialIdentityServerMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.AccessToken", b => + { + b.Property("InternalCommandId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("internal_command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_ip"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("InternalCommandId") + .HasName("pk_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_access_tokens_user_id"); + + b.HasIndex("Token", "UserId") + .IsUnique() + .HasDatabaseName("ix_access_tokens_token_user_id"); + + b.ToTable("access_tokens", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("asp_net_roles", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("LastLoggedInAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in_at"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("user_name"); + + b.Property("UserState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Active") + .HasColumnName("user_state"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_email"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("asp_net_users", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("asp_net_user_roles", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.EmailVerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character(6)") + .HasColumnName("code") + .IsFixedLength(); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id") + .HasName("pk_email_verification_codes"); + + b.ToTable("email_verification_codes", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character(6)") + .HasColumnName("code") + .IsFixedLength(); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id") + .HasName("pk_password_reset_codes"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.RefreshToken", b => + { + b.Property("InternalCommandId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("internal_command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_ip"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("InternalCommandId") + .HasName("pk_refresh_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.HasIndex("Token", "UserId") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("asp_net_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("asp_net_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("asp_net_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("asp_net_user_tokens", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.AccessToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "ApplicationUser") + .WithMany("AccessTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_access_tokens_asp_net_users_user_id"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUserRole", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.RefreshToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "ApplicationUser") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", b => + { + b.Navigation("AccessTokens"); + + b.Navigation("RefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.cs new file mode 100644 index 00000000..efc50722 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/20240719224959_InitialIdentityServerMigration.cs @@ -0,0 +1,344 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Identity.Shared.Data.Migrations.Identity +{ + /// + public partial class InitialIdentityServerMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "asp_net_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_roles", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "asp_net_users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + first_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + last_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + last_logged_in_at = table.Column(type: "timestamp with time zone", nullable: true), + user_state = table.Column(type: "text", nullable: false, defaultValue: "Active"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + user_name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + normalized_user_name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + email = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + normalized_email = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + email_confirmed = table.Column(type: "boolean", nullable: false), + password_hash = table.Column(type: "text", nullable: true), + security_stamp = table.Column(type: "text", nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true), + phone_number = table.Column(type: "character varying(15)", maxLength: 15, nullable: true), + phone_number_confirmed = table.Column(type: "boolean", nullable: false), + two_factor_enabled = table.Column(type: "boolean", nullable: false), + lockout_end = table.Column(type: "timestamp with time zone", nullable: true), + lockout_enabled = table.Column(type: "boolean", nullable: false), + access_failed_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "email_verification_codes", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + code = table.Column(type: "character(6)", fixedLength: true, maxLength: 6, nullable: false), + sent_at = table.Column(type: "timestamp with time zone", nullable: false), + used_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_email_verification_codes", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "password_reset_codes", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + code = table.Column(type: "character(6)", fixedLength: true, maxLength: 6, nullable: false), + sent_at = table.Column(type: "timestamp with time zone", nullable: false), + used_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_password_reset_codes", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "asp_net_role_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + role_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_role_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_role_claims_asp_net_roles_role_id", + column: x => x.role_id, + principalTable: "asp_net_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "access_tokens", + columns: table => new + { + internal_command_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expired_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by_ip = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_access_tokens", x => x.internal_command_id); + table.ForeignKey( + name: "fk_access_tokens_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_claims", x => x.id); + table.ForeignKey( + name: "fk_asp_net_user_claims_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_logins", + columns: table => new + { + login_provider = table.Column(type: "text", nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "text", nullable: true), + user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_logins", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_asp_net_user_logins_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_roles", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_roles", x => new { x.user_id, x.role_id }); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_roles_role_id", + column: x => x.role_id, + principalTable: "asp_net_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_asp_net_user_roles_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "asp_net_user_tokens", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + login_provider = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_asp_net_user_tokens", x => new { x.user_id, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_asp_net_user_tokens_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + internal_command_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expired_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by_ip = table.Column(type: "text", nullable: false), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_refresh_tokens", x => x.internal_command_id); + table.ForeignKey( + name: "fk_refresh_tokens_asp_net_users_user_id", + column: x => x.user_id, + principalTable: "asp_net_users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_access_tokens_token_user_id", + table: "access_tokens", + columns: new[] { "token", "user_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_access_tokens_user_id", + table: "access_tokens", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_role_claims_role_id", + table: "asp_net_role_claims", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "asp_net_roles", + column: "normalized_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_claims_user_id", + table: "asp_net_user_claims", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_logins_user_id", + table: "asp_net_user_logins", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_user_roles_role_id", + table: "asp_net_user_roles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "asp_net_users", + column: "normalized_email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_asp_net_users_email", + table: "asp_net_users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "asp_net_users", + column: "normalized_user_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_token_user_id", + table: "refresh_tokens", + columns: new[] { "token", "user_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_user_id", + table: "refresh_tokens", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "access_tokens"); + + migrationBuilder.DropTable( + name: "asp_net_role_claims"); + + migrationBuilder.DropTable( + name: "asp_net_user_claims"); + + migrationBuilder.DropTable( + name: "asp_net_user_logins"); + + migrationBuilder.DropTable( + name: "asp_net_user_roles"); + + migrationBuilder.DropTable( + name: "asp_net_user_tokens"); + + migrationBuilder.DropTable( + name: "email_verification_codes"); + + migrationBuilder.DropTable( + name: "password_reset_codes"); + + migrationBuilder.DropTable( + name: "refresh_tokens"); + + migrationBuilder.DropTable( + name: "asp_net_roles"); + + migrationBuilder.DropTable( + name: "asp_net_users"); + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/IdentityContextModelSnapshot.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/IdentityContextModelSnapshot.cs new file mode 100644 index 00000000..d470d476 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Data/Migrations/Identity/IdentityContextModelSnapshot.cs @@ -0,0 +1,565 @@ +// +using System; +using FoodDelivery.Services.Identity.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Identity.Shared.Data.Migrations.Identity +{ + [DbContext(typeof(IdentityContext))] + partial class IdentityContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.AccessToken", b => + { + b.Property("InternalCommandId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("internal_command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_ip"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("InternalCommandId") + .HasName("pk_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_access_tokens_user_id"); + + b.HasIndex("Token", "UserId") + .IsUnique() + .HasDatabaseName("ix_access_tokens_token_user_id"); + + b.ToTable("access_tokens", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_asp_net_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("asp_net_roles", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("LastLoggedInAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in_at"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("user_name"); + + b.Property("UserState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Active") + .HasColumnName("user_state"); + + b.HasKey("Id") + .HasName("pk_asp_net_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_asp_net_users_email"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("asp_net_users", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_asp_net_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_user_roles_role_id"); + + b.ToTable("asp_net_user_roles", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.EmailVerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character(6)") + .HasColumnName("code") + .IsFixedLength(); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id") + .HasName("pk_email_verification_codes"); + + b.ToTable("email_verification_codes", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character(6)") + .HasColumnName("code") + .IsFixedLength(); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("email"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id") + .HasName("pk_password_reset_codes"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.RefreshToken", b => + { + b.Property("InternalCommandId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("internal_command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_ip"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("InternalCommandId") + .HasName("pk_refresh_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.HasIndex("Token", "UserId") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_user_id"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_asp_net_role_claims_role_id"); + + b.ToTable("asp_net_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_asp_net_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_claims_user_id"); + + b.ToTable("asp_net_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_asp_net_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_asp_net_user_logins_user_id"); + + b.ToTable("asp_net_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_asp_net_user_tokens"); + + b.ToTable("asp_net_user_tokens", (string)null); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.AccessToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "ApplicationUser") + .WithMany("AccessTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_access_tokens_asp_net_users_user_id"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUserRole", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_roles_role_id"); + + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_roles_asp_net_users_user_id"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.RefreshToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", "ApplicationUser") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_asp_net_users_user_id"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_role_claims_asp_net_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_claims_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_logins_asp_net_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_asp_net_user_tokens_asp_net_users_user_id"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Identity.Shared.Models.ApplicationUser", b => + { + b.Navigation("AccessTokens"); + + b.Navigation("RefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Exceptions/IdentityUserNotFoundException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Exceptions/IdentityUserNotFoundException.cs new file mode 100644 index 00000000..908fcb7d --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Exceptions/IdentityUserNotFoundException.cs @@ -0,0 +1,12 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Shared.Exceptions; + +public class IdentityUserNotFoundException : AppException +{ + public IdentityUserNotFoundException(string emailOrUserName) + : base($"User with email or username: '{emailOrUserName}' not found.", StatusCodes.Status404NotFound) { } + + public IdentityUserNotFoundException(Guid id) + : base($"User with id: '{id}' not found.", StatusCodes.Status404NotFound) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/UserManagerExtensions.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/UserManagerExtensions.cs new file mode 100644 index 00000000..454cb53e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/UserManagerExtensions.cs @@ -0,0 +1,82 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Sieve.Services; + +namespace FoodDelivery.Services.Identity.Shared.Extensions; + +public static class UserManagerExtensions +{ + public static async Task> FindAllUserWithRoleAsync( + this UserManager userManager + ) + { + return await userManager.Users.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ToListAsync(); + } + + public static async Task> FindAllUsersByPageAsync( + this UserManager userManager, + IPageRequest request, + IMapper mapper, + ISieveProcessor sieveProcessor, + CancellationToken cancellationToken + ) + where TResult : class + { + // https://benjii.me/2018/01/expression-projection-magic-entity-framework-core/ + // we don't use include for loading nested navigation because with mapping we load them explicitly + return await userManager.Users + .OrderByDescending(x => x.CreatedAt) + .AsNoTracking() + .ApplyPagingAsync( + request, + mapper.ConfigurationProvider, + sieveProcessor, + cancellationToken: cancellationToken + ); + } + + public static async Task FindUserWithRoleByIdAsync( + this UserManager userManager, + Guid userId + ) + { + return await userManager.Users + .Include(u => u.UserRoles) + .ThenInclude(ur => ur.Role) + .Include(x => x.AccessTokens) + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.Id == userId); + } + + public static async Task FindUserWithRoleByUserNameAsync( + this UserManager userManager, + string userName + ) + { + return await userManager.Users + .Include(u => u.UserRoles) + .ThenInclude(ur => ur.Role) + .Include(x => x.AccessTokens) + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.UserName == userName); + } + + public static async Task FindUserWithRoleByEmailAsync( + this UserManager userManager, + string email + ) + { + return await userManager.Users + .Include(u => u.UserRoles) + .ThenInclude(ur => ur.Role) + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.Email == email); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Identity.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Identity.cs new file mode 100644 index 00000000..59ce32bf --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Identity.cs @@ -0,0 +1,75 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Persistence.EfCore.Postgres; +using FoodDelivery.Services.Identity.Shared.Data; +using FoodDelivery.Services.Identity.Shared.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddCustomIdentity( + this WebApplicationBuilder builder, + IConfiguration configuration, + Action? configure = null + ) + { + builder.Services.AddValidatedOptions(); + var postgresOptions = builder.Configuration.BindOptions(); + + if (postgresOptions.UseInMemory) + { + builder.Services.AddDbContext( + options => options.UseInMemoryDatabase("Shop.Services.FoodDelivery.Services.Identity") + ); + + builder.Services.TryAddScoped(provider => provider.GetService()!); + builder.Services.TryAddScoped(provider => provider.GetService()!); + } + else + { + // Postgres + builder.Services.AddPostgresDbContext(configuration); + + // add migrations and seeders dependencies, or we could add seeders inner each modules + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + } + + // Problem with .net core identity - will override our default authentication scheme `JwtBearerDefaults.AuthenticationScheme` to unwanted `FoodDelivery.Services.Identity.Application` in `AddIdentity()` method .net identity + // https://github.com/IdentityServer/IdentityServer4/issues/1525 + // https://github.com/IdentityServer/IdentityServer4/issues/1525 + // some dependencies will add here if not registered before + builder.Services + .AddIdentity(options => + { + // Password settings. + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequiredLength = 3; + options.Password.RequiredUniqueChars = 1; + + // Lockout settings. + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // User settings. + options.User.RequireUniqueEmail = true; + + if (configure is { }) + configure.Invoke(options); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + return builder; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/IdentityServer.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/IdentityServer.cs new file mode 100644 index 00000000..97a8d06e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/IdentityServer.cs @@ -0,0 +1,30 @@ +using FoodDelivery.Services.Identity.Identity; +using FoodDelivery.Services.Identity.Identity.Services; +using FoodDelivery.Services.Identity.Shared.Models; + +// Ref:https://www.scottbrady91.com/identity-server/getting-started-with-identityserver-4 +namespace FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddCustomIdentityServer(this WebApplicationBuilder builder) + { + builder.Services + .AddIdentityServer(options => + { + options.Events.RaiseErrorEvents = true; + options.Events.RaiseInformationEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseSuccessEvents = true; + }) + .AddInMemoryIdentityResources(IdentityServerConfig.IdentityResources) + .AddInMemoryApiResources(IdentityServerConfig.ApiResources) + .AddInMemoryApiScopes(IdentityServerConfig.ApiScopes) + .AddInMemoryClients(IdentityServerConfig.Clients) + .AddAspNetIdentity() + .AddProfileService() + .AddDeveloperSigningCredential(); // This is for dev only scenarios when you don’t have a certificate to use.; + + return builder; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs new file mode 100644 index 00000000..ecb071b1 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -0,0 +1,120 @@ +using BuildingBlocks.Caching; +using BuildingBlocks.Caching.Behaviours; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Email; +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Integration.MassTransit; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.OpenTelemetry; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Security.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Swagger; +using BuildingBlocks.Validation; +using BuildingBlocks.Validation.Extensions; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Identity.Users; + +namespace FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder) + { + builder.Services.AddCore(); + + builder.Services.AddCustomJwtAuthentication(builder.Configuration); + builder.Services.AddCustomAuthorization( + rolePolicies: new List + { + new(IdentityConstants.Role.Admin, new List { IdentityConstants.Role.Admin }), + new(IdentityConstants.Role.User, new List { IdentityConstants.Role.User }) + } + ); + + // https://www.michaco.net/blog/EnvironmentVariablesAndConfigurationInASPNETCoreApps#environment-variables-and-configuration + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#non-prefixed-environment-variables + builder.Configuration.AddEnvironmentVariables("food_delivery_identity_env_"); + + // https://github.com/tonerdo/dotnet-env + DotNetEnv.Env.TraversePath().Load(); + + builder.AddCompression(); + + builder.AddAppProblemDetails(); + + builder.AddCustomSerilog(); + + builder.AddCustomVersioning(); + + builder.AddCustomSwagger(); + + builder.AddCustomCors(); + + builder.AddCustomOpenTelemetry(); + + builder.Services.AddHttpContextAccessor(); + + if (builder.Environment.IsTest() == false) + { + builder.AddCustomHealthCheck(healthChecksBuilder => + { + var postgresOptions = builder.Configuration.BindOptions(); + var rabbitMqOptions = builder.Configuration.BindOptions(); + + healthChecksBuilder + .AddNpgSql( + postgresOptions.ConnectionString, + name: "IdentityService-Postgres-Check", + tags: new[] { "postgres", "database", "infra", "identity-service", "live", "ready" } + ) + .AddRabbitMQ( + rabbitMqOptions.ConnectionString, + name: "IdentityService-RabbitMQ-Check", + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "rabbitmq", "bus", "infra", "identity-service", "live", "ready" } + ); + }); + } + + builder.Services.AddEmailService(builder.Configuration); + + builder.Services.AddCqrs( + pipelines: new[] + { + typeof(LoggingBehavior<,>), + typeof(StreamLoggingBehavior<,>), + typeof(RequestValidationBehavior<,>), + typeof(StreamRequestValidationBehavior<,>), + typeof(StreamCachingBehavior<,>), + typeof(CachingBehavior<,>), + typeof(InvalidateCachingBehavior<,>), + typeof(EfTxBehavior<,>) + } + ); + + builder.Services.AddPostgresMessagePersistence(builder.Configuration); + + // https://blog.maartenballiauw.be/post/2022/09/26/aspnet-core-rate-limiting-middleware.html + builder.AddCustomRateLimit(); + + builder.AddCustomMassTransit( + (context, cfg) => + { + cfg.AddUserPublishers(); + }, + autoConfigEndpoints: false + ); + + builder.AddCustomEasyCaching(); + + builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); + + builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); + + return builder; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs new file mode 100644 index 00000000..68060277 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Web.Problem; +using Microsoft.AspNetCore.Diagnostics; + +namespace FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; + +public static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddAppProblemDetails(this WebApplicationBuilder builder) + { + builder.Services.AddCustomProblemDetails(problemDetailsOptions => + { + // customization problem details should go here + problemDetailsOptions.CustomizeProblemDetails = problemDetailContext => + { + // with help of capture exception middleware for capturing actual exception + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + if (problemDetailContext.HttpContext.Features.Get() is { } exceptionFeature) + { } + }; + }); + + return builder; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs new file mode 100644 index 00000000..3f8bbfc6 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs @@ -0,0 +1,71 @@ +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; +using BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; +using FoodDelivery.Services.Catalogs; +using Serilog; + +namespace FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationExtensions; + +public static partial class WebApplicationExtensions +{ + public static async Task UseInfrastructure(this WebApplication app) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling + // Does nothing if a response body has already been provided. when our next `DeveloperExceptionMiddleware` is written response for exception (in dev mode) when we back to `ExceptionHandlerMiddlewareImpl` because `context.Response.HasStarted` it doesn't do anything + // By default `ExceptionHandlerMiddlewareImpl` middleware register original exceptions with `IExceptionHandlerFeature` feature, we don't have this in `DeveloperExceptionPageMiddleware` and we should handle it with a middleware like `CaptureExceptionMiddleware` + // Just for handling exceptions in production mode + // https://github.com/dotnet/aspnetcore/pull/26567 + app.UseExceptionHandler(new ExceptionHandlerOptions { AllowStatusCode404Response = true }); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment() || app.Environment.IsTest()) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/handle-errrors + app.UseDeveloperExceptionPage(); + + // https://github.com/dotnet/aspnetcore/issues/47651 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + app.UseCaptureException(); + } + + // this middleware should be first middleware + // request logging just log in information level and above as default + app.UseSerilogRequestLogging(opts => + { + opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest; + + // this level wil use for request logging + // https://andrewlock.net/using-serilog-aspnetcore-in-asp-net-core-3-excluding-health-check-endpoints-from-serilog-request-logging/#customising-the-log-level-used-for-serilog-request-logs + opts.GetLevel = LogEnricher.GetLogLevel; + }); + + app.UseRequestLogContextMiddleware(); + + app.UseCustomCors(); + + app.UseAuthentication(); + app.UseAuthorization(); + + await app.UsePostgresPersistenceMessage(app.Logger); + + await app.MigrateDatabases(); + + app.UseCustomRateLimit(); + + if (app.Environment.IsTest() == false) + { + app.UseCustomHealthCheck(); + app.UseIdentityServer(); + } + + // Configure the prometheus endpoint for scraping metrics + // NOTE: This should only be exposed on an internal port! + // .RequireHost("*:9100"); + app.MapPrometheusScrapingEndpoint(); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Migration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Migration.cs new file mode 100644 index 00000000..6040cbd9 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationExtensions/Migration.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Catalogs; + +public static partial class WebApplicationExtensions +{ + public static async Task MigrateDatabases(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var migrationManager = scope.ServiceProvider.GetRequiredService(); + + await migrationManager.ExecuteAsync(CancellationToken.None); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/AccessToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/AccessToken.cs new file mode 100644 index 00000000..7fe52ae9 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/AccessToken.cs @@ -0,0 +1,11 @@ +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class AccessToken +{ + public Guid UserId { get; set; } + public string Token { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public DateTime ExpiredAt { get; set; } + public string CreatedByIp { get; set; } = default!; + public ApplicationUser? ApplicationUser { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationRole.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationRole.cs new file mode 100644 index 00000000..aeb3ce3c --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationRole.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class ApplicationRole : IdentityRole +{ + public virtual ICollection UserRoles { get; set; } = default!; + public static ApplicationRole User => + new() + { + Name = IdentityConstants.Role.User, + NormalizedName = nameof(User).ToUpper(CultureInfo.InvariantCulture), + }; + + public static ApplicationRole Admin => + new() + { + Name = IdentityConstants.Role.Admin, + NormalizedName = nameof(Admin).ToUpper(CultureInfo.InvariantCulture) + }; +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUser.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUser.cs new file mode 100644 index 00000000..6297c02a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUser.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class ApplicationUser : IdentityUser +{ + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public DateTime? LastLoggedInAt { get; set; } + + // .Net identity navigations -> https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model#add-navigation-properties + // Only Used by DbContext + public virtual ICollection RefreshTokens { get; set; } = default!; + public virtual ICollection AccessTokens { get; set; } = default!; + public virtual ICollection UserRoles { get; set; } = default!; + public UserState UserState { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUserRole.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUserRole.cs new file mode 100644 index 00000000..59d0fbff --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/ApplicationUserRole.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class ApplicationUserRole : IdentityUserRole +{ + public virtual ApplicationUser? User { get; set; } + public virtual ApplicationRole? Role { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/EmailVerificationCode.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/EmailVerificationCode.cs new file mode 100644 index 00000000..75722310 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/EmailVerificationCode.cs @@ -0,0 +1,15 @@ +namespace FoodDelivery.Services.Identity.Shared.Models +{ + public class EmailVerificationCode + { + public Guid Id { get; set; } + + public string Email { get; set; } + + public string Code { get; set; } + + public DateTime SentAt { get; set; } + + public DateTime? UsedAt { get; set; } + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/PasswordResetCode.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/PasswordResetCode.cs new file mode 100644 index 00000000..043b276b --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/PasswordResetCode.cs @@ -0,0 +1,14 @@ +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class PasswordResetCode +{ + public Guid Id { get; set; } + + public string Email { get; set; } + + public string Code { get; set; } + + public DateTime SentAt { get; set; } + + public DateTime? UsedAt { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/RefreshToken.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/RefreshToken.cs new file mode 100644 index 00000000..1d612c9d --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/RefreshToken.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; + +namespace FoodDelivery.Services.Identity.Shared.Models; + +public class RefreshToken +{ + public Guid UserId { get; set; } + public string Token { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime ExpiredAt { get; set; } + public string CreatedByIp { get; set; } + public bool IsExpired => DateTime.Now >= ExpiredAt; + public bool IsRevoked => RevokedAt != null; + public bool IsActive => !IsRevoked && !IsExpired; + public DateTime? RevokedAt { get; set; } + public ApplicationUser ApplicationUser { get; set; } + + public static string GetRefreshToken() + { + var randomNumber = new byte[32]; + using var randomNumberGenerator = RandomNumberGenerator.Create(); + randomNumberGenerator.GetBytes(randomNumber); + + var refreshToken = Convert.ToBase64String(randomNumber); + + return refreshToken; + } + + public bool IsRefreshTokenValid(double? ttlRefreshToken = null) + { + // Token already expired or revoked, then return false + if (!IsActive) + return false; + + if (ttlRefreshToken is not null && CreatedAt.AddDays((long)ttlRefreshToken) <= DateTime.Now) + return false; + + return true; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/UserState.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/UserState.cs new file mode 100644 index 00000000..4b6800a8 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Models/UserState.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Identity.Shared.Models; + +public enum UserState +{ + Active = 1, + Locked = 2 +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/SharedModulesConfiguration.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/SharedModulesConfiguration.cs new file mode 100644 index 00000000..45482e50 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/SharedModulesConfiguration.cs @@ -0,0 +1,48 @@ +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core; +using FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationBuilderExtensions; +using FoodDelivery.Services.Identity.Shared.Extensions.WebApplicationExtensions; + +namespace FoodDelivery.Services.Identity.Shared; + +public class SharedModulesConfiguration : ISharedModulesConfiguration +{ + public const string IdentityModulePrefixUri = "api/v{version:apiVersion}/identity"; + + public WebApplicationBuilder AddSharedModuleServices(WebApplicationBuilder builder) + { + builder.AddInfrastructure(); + return builder; + } + + public async Task ConfigureSharedModule(WebApplication app) + { + await app.UseInfrastructure(); + + ServiceActivator.Configure(app.Services); + + return app; + } + + public IEndpointRouteBuilder MapSharedModuleEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints + .MapGet( + "/", + (HttpContext context) => + { + var requestId = context.Request.Headers.TryGetValue( + "X-Request-InternalCommandId", + out var requestIdHeader + ) + ? requestIdHeader.FirstOrDefault() + : string.Empty; + + return $"Identity Service Apis, RequestId: {requestId}"; + } + ) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Dtos/v1/IdentityUserDto.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Dtos/v1/IdentityUserDto.cs new file mode 100644 index 00000000..8c9a0ed1 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Dtos/v1/IdentityUserDto.cs @@ -0,0 +1,18 @@ +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Users.Dtos.v1; + +public class IdentityUserDto +{ + public Guid Id { get; set; } + public string UserName { get; set; } = default!; + public string Email { get; set; } = default!; + public string? PhoneNumber { get; set; } + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public DateTime? LastLoggedInAt { get; set; } + public IEnumerable? RefreshTokens { get; set; } + public IEnumerable? Roles { get; set; } + public UserState UserState { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmail.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmail.cs new file mode 100644 index 00000000..66637a6f --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmail.cs @@ -0,0 +1,59 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Extensions; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FluentValidation; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUerByEmail.v1; + +internal record GetUserByEmail(string Email) : IQuery +{ + /// + /// GetUserByEmail with in-line validation. + /// + /// + /// + public static GetUserByEmail Of(string? email) + { + return new GetUserByIdValidator().HandleValidation(new GetUserByEmail(email!)); + } +} + +internal class GetUserByIdValidator : AbstractValidator +{ + public GetUserByIdValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Email address is not valid"); + } +} + +internal class GetUserByEmailHandler : IQueryHandler +{ + private readonly UserManager _userManager; + private readonly IMapper _mapper; + + public GetUserByEmailHandler(UserManager userManager, IMapper mapper) + { + _userManager = userManager; + _mapper = mapper; + } + + public async Task Handle(GetUserByEmail query, CancellationToken cancellationToken) + { + query.NotBeNull(); + + var identityUser = await _userManager.FindUserWithRoleByEmailAsync(query.Email); + identityUser.NotBeNull(new IdentityUserNotFoundException(query.Email)); + + var userDto = _mapper.Map(identityUser); + + return new GetUserByEmailResult(userDto); + } +} + +internal record GetUserByEmailResult(IdentityUserDto? UserIdentity); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmailEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmailEndpoint.cs new file mode 100644 index 00000000..3e21599e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUerByEmail/v1/GetUserByEmailEndpoint.cs @@ -0,0 +1,55 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUerByEmail.v1; + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis +public static class GetUserByEmailEndpoint +{ + internal static RouteHandlerBuilder MapGetUserByEmailEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/by-email/{email}", Handle) + .AllowAnonymous() + .WithTags(UsersConfigs.Tag) + .WithName(nameof(GetUserByEmail)) + .WithDisplayName(nameof(GetUserByEmail).Humanize()) + .WithSummaryAndDescription(nameof(GetUserByEmail).Humanize(), nameof(GetUserByEmail).Humanize()) + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status200OK) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .MapToApiVersion(1.0); + + async Task, ValidationProblem, NotFoundHttpProblemResult>> Handle( + [AsParameters] GetUserByEmailRequestParameters requestParameters + ) + { + var (email, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetUserByEmail.Of(email), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetUserByEmailResponse(result.UserIdentity)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetUserByEmailRequestParameters( + [FromRoute] string Email, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +internal record GetUserByEmailResponse(IdentityUserDto? UserIdentity); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserById.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserById.cs new file mode 100644 index 00000000..101d1bb8 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserById.cs @@ -0,0 +1,50 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Extensions; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FluentValidation; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUserById.v1; + +internal record GetUserById(Guid Id) : IQuery +{ + public static GetUserById Of(Guid id) => new GetUserById(id.NotBeEmpty()); +} + +internal class GetUserByIdValidator : AbstractValidator +{ + public GetUserByIdValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("InternalCommandId is required."); + } +} + +internal class GetUserByIdHandler : IQueryHandler +{ + private readonly IMapper _mapper; + private readonly UserManager _userManager; + + public GetUserByIdHandler(UserManager userManager, IMapper mapper) + { + _mapper = mapper; + _userManager = userManager; + } + + public async Task Handle(GetUserById query, CancellationToken cancellationToken) + { + query.NotBeNull(); + + var identityUser = await _userManager.FindUserWithRoleByIdAsync(query.Id); + identityUser.NotBeNull(new IdentityUserNotFoundException(query.Id)); + + var identityUserDto = _mapper.Map(identityUser); + + return new GetUserByIdResult(identityUserDto); + } +} + +internal record GetUserByIdResult(IdentityUserDto IdentityUser); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserByIdEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserByIdEndpoint.cs new file mode 100644 index 00000000..701e4e94 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUserById/v1/GetUserByIdEndpoint.cs @@ -0,0 +1,52 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUserById.v1; + +public static class GetUserByIdEndpoint +{ + internal static RouteHandlerBuilder MapGetUserByIdEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/{userId:guid}", Handle) + .AllowAnonymous() + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status200OK) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(GetUserById)) + .WithDisplayName(nameof(GetUserById).Humanize()) + .WithSummaryAndDescription(nameof(GetUserById).Humanize(), nameof(GetUserById).Humanize()) + .MapToApiVersion(1.0); + + async Task, ValidationProblem, NotFoundHttpProblemResult>> Handle( + [AsParameters] GetUserByIdRequestParameters requestParameters + ) + { + var (userId, _, queryProcessor, mapper, cancellationToken) = requestParameters; + var result = await queryProcessor.SendAsync(GetUserById.Of(userId), cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetUserByIdResponse(result.IdentityUser)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetUserByIdRequestParameters( + [FromRoute] Guid UserId, + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpQuery; + +internal record GetUserByIdResponse(IdentityUserDto? UserIdentity); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsers.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsers.cs new file mode 100644 index 00000000..69956c50 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsers.cs @@ -0,0 +1,79 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Core.CQRS.Queries; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Shared.Extensions; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using Sieve.Services; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUsers.v1; + +internal record GetUsers : PageQuery +{ + /// + /// GetUsers with in-line validator. + /// + /// + /// + public static GetUsers Of(PageRequest pageRequest) + { + var (pageNumber, pageSize, filters, sortOrder) = pageRequest; + + return new GetUsersValidator().HandleValidation( + new GetUsers + { + PageNumber = pageNumber, + PageSize = pageSize, + Filters = filters, + SortOrder = sortOrder + } + ); + } +} + +internal class GetUsersValidator : AbstractValidator +{ + public GetUsersValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage("Page should at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage("PageSize should at least greater than or equal to 1."); + } +} + +internal class GetUsersHandler : IQueryHandler +{ + private readonly UserManager _userManager; + private readonly IMapper _mapper; + private readonly ISieveProcessor _sieveProcessor; + + public GetUsersHandler(UserManager userManager, IMapper mapper, ISieveProcessor sieveProcessor) + { + _userManager = userManager; + _mapper = mapper; + _sieveProcessor = sieveProcessor; + } + + public async Task Handle(GetUsers request, CancellationToken cancellationToken) + { + var users = await _userManager.FindAllUsersByPageAsync( + request, + _mapper, + _sieveProcessor, + cancellationToken + ); + + return new GetUsersResult(users); + } +} + +internal record GetUsersResult(IPageList IdentityUsers); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsersEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsersEndpoint.cs new file mode 100644 index 00000000..d2b9e84f --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/GettingUsers/v1/GetUsersEndpoint.cs @@ -0,0 +1,65 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.Core.Paging; +using BuildingBlocks.Abstractions.CQRS.Queries; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Core.Paging; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Users.Features.GettingUsers.v1; + +internal static class GetUsersEndpoint +{ + internal static RouteHandlerBuilder MapGetUsersByPageEndpoint(this IEndpointRouteBuilder app) + { + return app.MapGet("/", Handle) + .RequireAuthorization() + .WithTags(UsersConfigs.Tag) + .WithName(nameof(GetUsers)) + .WithSummaryAndDescription(nameof(GetUsers).Humanize(), nameof(GetUsers).Humanize()) + .WithDisplayName(nameof(GetUsers).Humanize()) + // Api Documentations will produce automatically by typed result in minimal apis. + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces("Products fetched successfully.", StatusCodes.Status200OK) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + // .ProducesProblem(StatusCodes.Status401Unauthorized) + .MapToApiVersion(1.0); + + async Task, ValidationProblem, UnAuthorizedHttpProblemResult>> Handle( + [AsParameters] GetUsersRequestParameters requestParameters + ) + { + var (context, queryProcessor, mapper, cancellationToken) = requestParameters; + + var query = GetUsers.Of( + new PageRequest + { + PageNumber = requestParameters.PageNumber, + PageSize = requestParameters.PageSize, + SortOrder = requestParameters.SortOrder, + Filters = requestParameters.SortOrder + } + ); + + var result = await queryProcessor.SendAsync(query, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.Ok(new GetUsersResponse(result.IdentityUsers)); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record GetUsersRequestParameters( + HttpContext HttpContext, + IQueryProcessor QueryProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : PageRequest, IHttpQuery; + +internal record GetUsersResponse(IPageList IdentityUsers); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterIdentityUserException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterIdentityUserException.cs new file mode 100644 index 00000000..8b03842a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterIdentityUserException.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Core.Exception.Types; + +namespace FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; + +public class RegisterIdentityUserException : AppException +{ + public RegisterIdentityUserException(string error) + : base(error, StatusCodes.Status500InternalServerError) { } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs new file mode 100644 index 00000000..433b4936 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs @@ -0,0 +1,173 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using UserState = FoodDelivery.Services.Identity.Shared.Models.UserState; + +namespace FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; + +public record RegisterUser( + string FirstName, + string LastName, + string UserName, + string Email, + string PhoneNumber, + string Password, + string ConfirmPassword, + IEnumerable? Roles = null +) : ITxCreateCommand +{ + public DateTime CreatedAt { get; init; } = DateTime.Now; + + /// + /// RegisterUser with in-line validation. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static RegisterUser Of( + string? firstName, + string? lastName, + string? userName, + string? email, + string? phoneNumber, + string? password, + string? confirmPassword, + IEnumerable? roles = null + ) + { + return new RegisterUserValidator().HandleValidation( + new RegisterUser(firstName!, lastName!, userName!, email!, phoneNumber!, password!, confirmPassword!, roles) + ); + } +} + +internal class RegisterUserValidator : AbstractValidator +{ + public RegisterUserValidator() + { + RuleFor(v => v.FirstName).NotEmpty().WithMessage("FirstName is required."); + RuleFor(v => v.LastName).NotEmpty().WithMessage("LastName is required."); + RuleFor(v => v.Email).NotEmpty().WithMessage("Email is required.").EmailAddress(); + RuleFor(v => v.UserName).NotEmpty().WithMessage("UserName is required."); + RuleFor(v => v.Password).NotEmpty().WithMessage("Password is required."); + RuleFor(p => p.PhoneNumber) + .NotEmpty() + .WithMessage("Phone Number is required.") + .MinimumLength(7) + .WithMessage("PhoneNumber must not be less than 7 characters.") + .MaximumLength(15) + .WithMessage("PhoneNumber must not exceed 15 characters."); + RuleFor(v => v.ConfirmPassword) + .Equal(x => x.Password) + .WithMessage("The password and confirmation password do not match.") + .NotEmpty(); + RuleFor(v => v.Roles) + .Custom( + (roles, c) => + { + if ( + roles != null + && !roles.All( + x => + x.Contains(IdentityConstants.Role.Admin, StringComparison.Ordinal) + || x.Contains(IdentityConstants.Role.User, StringComparison.Ordinal) + ) + ) + { + c.AddFailure("Invalid roles."); + } + } + ); + } +} + +// using transaction script instead of using domain business logic here +// https://www.youtube.com/watch?v=PrJIMTZsbDw +internal class RegisterUserHandler : ICommandHandler +{ + private readonly IMessagePersistenceService _messagePersistenceService; + + private readonly UserManager _userManager; + + public RegisterUserHandler( + UserManager userManager, + IMessagePersistenceService messagePersistenceService + ) + { + _messagePersistenceService = messagePersistenceService; + _userManager = userManager; + } + + public async Task Handle(RegisterUser request, CancellationToken cancellationToken) + { + var applicationUser = new ApplicationUser + { + FirstName = request.FirstName, + LastName = request.LastName, + UserName = request.UserName, + Email = request.Email, + PhoneNumber = request.PhoneNumber, + UserState = UserState.Active, + CreatedAt = request.CreatedAt, + }; + + var identityResult = await _userManager.CreateAsync(applicationUser, request.Password); + if (!identityResult.Succeeded) + throw new RegisterIdentityUserException(string.Join(',', identityResult.Errors.Select(e => e.Description))); + + var roleResult = await _userManager.AddToRolesAsync( + applicationUser, + request.Roles ?? new List { IdentityConstants.Role.User } + ); + + if (!roleResult.Succeeded) + throw new RegisterIdentityUserException(string.Join(',', roleResult.Errors.Select(e => e.Description))); + + var userRegistered = UserRegisteredV1.Of( + applicationUser.Id, + applicationUser.Email, + applicationUser.PhoneNumber!, + applicationUser.UserName, + applicationUser.FirstName, + applicationUser.LastName, + request.Roles + ); + + // publish our integration event and save to outbox should do in same transaction of our business logic actions. we could use TxBehaviour or ITxDbContextExecutes interface + // This service is not DDD, so we couldn't use DomainEventPublisher to publish mapped integration events + await _messagePersistenceService.AddPublishMessageAsync( + new MessageEnvelope(userRegistered, new Dictionary()), + cancellationToken + ); + + return new RegisterUserResult( + new IdentityUserDto + { + Id = applicationUser.Id, + Email = applicationUser.Email, + PhoneNumber = applicationUser.PhoneNumber, + UserName = applicationUser.UserName, + FirstName = applicationUser.FirstName, + LastName = applicationUser.LastName, + Roles = request.Roles ?? new List { IdentityConstants.Role.User }, + RefreshTokens = applicationUser?.RefreshTokens?.Select(x => x.Token), + CreatedAt = request.CreatedAt, + UserState = UserState.Active + } + ); + } +} + +internal record RegisterUserResult(IdentityUserDto? UserIdentity); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUserEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUserEndpoint.cs new file mode 100644 index 00000000..d78e8b9a --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUserEndpoint.cs @@ -0,0 +1,72 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FoodDelivery.Services.Identity.Users.Features.GettingUserById.v1; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; + +public static class RegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapRegisterNewUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost("/", Handle) + .AllowAnonymous() + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status201Created) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(RegisterUser)) + .WithSummaryAndDescription(nameof(RegisterUser).Humanize(), nameof(RegisterUser).Humanize()) + .WithDisplayName(nameof(RegisterUser).Humanize()) + .MapToApiVersion(1.0); + + async Task, ValidationProblem>> Handle( + [AsParameters] RegisterUserRequestParameters requestParameters + ) + { + var (request, context, commandProcessor, mapper, cancellationToken) = requestParameters; + + var command = mapper.Map(request); + + var result = await commandProcessor.SendAsync(command, cancellationToken); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-7.0#multiple-response-types + return TypedResults.CreatedAtRoute( + new RegisterUserResponse(result.UserIdentity), + nameof(GetUserById), + new { id = result.UserIdentity?.Id } + ); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record RegisterUserRequestParameters( + [FromBody] RegisterUserRequest Request, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +// parameters can be pass as null from the user +internal record RegisterUserRequest( + string? FirstName, + string? LastName, + string? UserName, + string? Email, + string? PhoneNumber, + string? Password, + string? ConfirmPassword +) +{ + public List Roles { get; init; } = new List { IdentityConstants.Role.User }; +} + +internal record RegisterUserResponse(IdentityUserDto? UserIdentity); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/Events/Integration/UserStateUpdated.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/Events/Integration/UserStateUpdated.cs new file mode 100644 index 00000000..760809e2 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/Events/Integration/UserStateUpdated.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1.Events.Integration; + +public record UserStateUpdated(Guid UserId, UserState OldUserState, UserState NewUserState) : IntegrationEvent +{ + /// + /// UserStateUpdated with in-line validation. + /// + /// + /// + /// + /// + public static UserStateUpdated Of(Guid userId, UserState oldUserState, UserState newUserState) => + new(userId.NotBeEmpty(), oldUserState, newUserState); +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserState.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserState.cs new file mode 100644 index 00000000..48a66b46 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserState.cs @@ -0,0 +1,90 @@ +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Validation.Extensions; +using FoodDelivery.Services.Identity.Shared.Exceptions; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1.Events.Integration; +using FluentValidation; +using Microsoft.AspNetCore.Identity; + +namespace FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1; + +internal record UpdateUserState(Guid UserId, UserState State) : ITxCommand +{ + /// + /// UpdateUserState with in-line validation. + /// + /// + /// + /// + public static UpdateUserState Of(Guid userId, UserState state) + { + return new UpdateUserStateValidator().HandleValidation(new UpdateUserState(userId, state)); + } +}; + +internal class UpdateUserStateValidator : AbstractValidator +{ + public UpdateUserStateValidator() + { + RuleFor(v => v.State).NotEmpty(); + RuleFor(v => v.UserId).NotEmpty(); + } +} + +internal class UpdateUserStateHandler : ICommandHandler +{ + private readonly IBus _bus; + private readonly ILogger _logger; + private readonly UserManager _userManager; + + public UpdateUserStateHandler( + IBus bus, + UserManager userManager, + ILogger logger + ) + { + _bus = bus; + _logger = logger; + _userManager = userManager; + } + + public async Task Handle(UpdateUserState request, CancellationToken cancellationToken) + { + var identityUser = await _userManager.FindByIdAsync(request.UserId.ToString()); + identityUser.NotBeNull(new IdentityUserNotFoundException(request.UserId)); + + var previousState = identityUser!.UserState; + if (previousState == request.State) + { + return Unit.Value; + } + + if (await _userManager.IsInRoleAsync(identityUser, IdentityConstants.Role.Admin)) + { + throw new UserStateCannotBeChangedException(request.State, request.UserId); + } + + identityUser.UserState = request.State; + + await _userManager.UpdateAsync(identityUser); + + var userStateUpdated = UserStateUpdated.Of( + request.UserId, + (UserState)(int)previousState, + (UserState)(int)request.State + ); + + await _bus.PublishAsync(userStateUpdated, null, cancellationToken); + + _logger.LogInformation( + "Updated state for user with ID: '{UserId}', '{PreviousState}' -> '{UserState}'", + identityUser.Id, + previousState, + identityUser.UserState + ); + + return Unit.Value; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserStateEndpoint.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserStateEndpoint.cs new file mode 100644 index 00000000..b9fdbc7e --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UpdateUserStateEndpoint.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using BuildingBlocks.Abstractions.CQRS.Commands; +using BuildingBlocks.Abstractions.Web.MinimalApi; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Problem.HttpResults; +using FoodDelivery.Services.Identity.Shared.Models; +using Humanizer; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1; + +internal static class UpdateUserStateEndpoint +{ + internal static RouteHandlerBuilder MapUpdateUserStateEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPut("/{userId:guid}/state", Handle) + .AllowAnonymous() + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?#typedresults-vs-results + // .Produces(StatusCodes.Status204NoContent) + // .ProducesProblem(StatusCodes.Status404NotFound) + // .ProducesValidationProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(UpdateUserState)) + .WithSummaryAndDescription(nameof(UpdateUserState).Humanize(), nameof(UpdateUserState).Humanize()) + .WithDisplayName(nameof(UpdateUserState).Humanize()) + .MapToApiVersion(1.0); + + async Task> Handle( + [AsParameters] UpdateUserStateRequestParameters requestParameters + ) + { + var (request, userId, context, commandProcessor, mapper, cancellationToken) = requestParameters; + var command = UpdateUserState.Of(userId, request.UserState); + + await commandProcessor.SendAsync(command, cancellationToken); + + return TypedResults.NoContent(); + } + } +} + +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#parameter-binding-for-argument-lists-with-asparameters +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding#binding-precedence +internal record UpdateUserStateRequestParameters( + [FromBody] UpdateUserStateRequest Request, + [FromRoute] Guid UserId, + HttpContext HttpContext, + ICommandProcessor CommandProcessor, + IMapper Mapper, + CancellationToken CancellationToken +) : IHttpCommand; + +internal record UpdateUserStateRequest(UserState UserState); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UserStateCannotBeChangedException.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UserStateCannotBeChangedException.cs new file mode 100644 index 00000000..7f77a648 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/UpdatingUserState/v1/UserStateCannotBeChangedException.cs @@ -0,0 +1,21 @@ +using BuildingBlocks.Core.Exception.Types; +using BuildingBlocks.Core.Types.Extensions; +using FoodDelivery.Services.Identity.Shared.Models; + +namespace FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1; + +internal class UserStateCannotBeChangedException : AppException +{ + public UserState State { get; } + public Guid UserId { get; } + + public UserStateCannotBeChangedException(UserState state, Guid userId) + : base( + $"User state cannot be changed to: '{state.ToName()}' for user with ID: '{userId}'.", + StatusCodes.Status500InternalServerError + ) + { + State = state; + UserId = userId; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/MassTransitExtensions.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/MassTransitExtensions.cs new file mode 100644 index 00000000..45f406d3 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/MassTransitExtensions.cs @@ -0,0 +1,29 @@ +using FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1.Events.Integration; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Identity.Users; + +internal static class MassTransitExtensions +{ + internal static void AddUserPublishers(this IRabbitMqBusFactoryConfigurator cfg) + { + cfg.Message(e => e.SetEntityName($"{nameof(UserRegisteredV1).Underscore()}.input_exchange")); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + + cfg.Message(e => e.SetEntityName($"{nameof(UserStateUpdated).Underscore()}.input_exchange")); // name of the primary exchange + cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type + cfg.Send(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersConfigs.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersConfigs.cs new file mode 100644 index 00000000..bc72d761 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersConfigs.cs @@ -0,0 +1,48 @@ +using BuildingBlocks.Abstractions.Web.Module; +using FoodDelivery.Services.Identity.Shared; +using FoodDelivery.Services.Identity.Users.Features.GettingUerByEmail.v1; +using FoodDelivery.Services.Identity.Users.Features.GettingUserById.v1; +using FoodDelivery.Services.Identity.Users.Features.GettingUsers.v1; +using FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; +using FoodDelivery.Services.Identity.Users.Features.UpdatingUserState.v1; + +namespace FoodDelivery.Services.Identity.Users; + +internal class UsersConfigs : IModuleConfiguration +{ + public const string Tag = "Users"; + public const string UsersPrefixUri = $"{SharedModulesConfiguration.IdentityModulePrefixUri}/users"; + + public WebApplicationBuilder AddModuleServices(WebApplicationBuilder builder) + { + return builder; + } + + public Task ConfigureModule(WebApplication app) + { + return Task.FromResult(app); + } + + public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints) + { + // https://github.com/dotnet/aspnet-api-versioning/commit/b789e7e980e83a7d2f82ce3b75235dee5e0724b4 + // changed from MapApiGroup to NewVersionedApi in v7.0.0 + var usersVersionGroup = endpoints.NewVersionedApi(Tag).WithTags(Tag); + + // create a new sub group for each version + var usersGroupV1 = usersVersionGroup.MapGroup(UsersPrefixUri).HasApiVersion(1.0); + + // create a new sub group for each version + var usersGroupV2 = usersVersionGroup.MapGroup(UsersPrefixUri).HasApiVersion(2.0); + + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-7.0#route-groups + // https://github.com/dotnet/aspnet-api-versioning/blob/main/examples/AspNetCore/WebApi/MinimalOpenApiExample/Program.cs + usersGroupV1.MapRegisterNewUserEndpoint(); + usersGroupV1.MapUpdateUserStateEndpoint(); + usersGroupV1.MapGetUserByIdEndpoint(); + usersGroupV1.MapGetUserByEmailEndpoint(); + usersGroupV1.MapGetUsersByPageEndpoint(); + + return endpoints; + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersMapping.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersMapping.cs new file mode 100644 index 00000000..53e7ea46 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/UsersMapping.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using FoodDelivery.Services.Identity.Shared.Models; +using FoodDelivery.Services.Identity.Users.Dtos.v1; +using FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; + +namespace FoodDelivery.Services.Identity.Users; + +public class UsersMapping : Profile +{ + public UsersMapping() + { + CreateMap(); + + CreateMap() + .ForMember(x => x.RefreshTokens, opt => opt.MapFrom(x => x.RefreshTokens.Select(r => r.Token))) + .ForMember( + x => x.Roles, + opt => opt.MapFrom(x => x.UserRoles.Where(m => m.Role != null).Select(q => q.Role!.Name)) + ); + } +} diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/readme.md b/src/Services/Identity/FoodDelivery.Services.Identity/readme.md new file mode 100644 index 00000000..9e4adc51 --- /dev/null +++ b/src/Services/Identity/FoodDelivery.Services.Identity/readme.md @@ -0,0 +1,6 @@ +#### Migration Scripts + +```bash +dotnet ef migrations add InitialIdentityServerMigration -o Shared/Data/Migrations/Identity -c IdentityContext +dotnet ef database update -c IdentityContext +``` diff --git a/src/Services/Identity/dev.Dockerfile b/src/Services/Identity/dev.Dockerfile new file mode 100644 index 00000000..2a0777b4 --- /dev/null +++ b/src/Services/Identity/dev.Dockerfile @@ -0,0 +1,110 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Identity/Directory.Build.props ./Services/Identity/ + +# TODO: Using wildcard to copy all files in the directory. +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Identity/FoodDelivery.Services.Identity/FoodDelivery.Services.Identity.csproj ./Services/Identity/FoodDelivery.Services.Identity/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj ./Services/Identity/FoodDelivery.Services.Identity.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Identity.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN --mount=type=cache,id=identity_nuget,target=/root/.nuget/packages \ + dotnet restore ./Services/Identity/FoodDelivery.Services.Identity.Api/FoodDelivery.Services.Identity.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity.Api/ ./Services/Identity/FoodDelivery.Services.Identity.Api/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity/ ./Services/Identity/FoodDelivery.Services.Identity/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Identity/FoodDelivery.Services.Identity.Api/ + +RUN --mount=type=cache,id=identity_nuget,target=/root/.nuget/packages\ + dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN --mount=type=cache,id=identity_nuget,target=/root/.nuget/packages\ + dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Identity.Api.dll"] diff --git a/src/Services/Identity/migrations.bat b/src/Services/Identity/migrations.bat new file mode 100644 index 00000000..628a00c0 --- /dev/null +++ b/src/Services/Identity/migrations.bat @@ -0,0 +1,4 @@ + +IF "%1"=="init-context" dotnet ef migrations add InitialIdentityServerMigration -o \FoodDelivery.Services.Identity\Shared\Data\Migrations\Identity --project .\FoodDelivery.Services.Identity\FoodDelivery.Services.Identity.csproj -c IdentityContext --verbose & goto exit +IF "%1"=="update-context" dotnet ef database update -c IdentityContext --verbose --project .\FoodDelivery.Services.Identity\FoodDelivery.Services.Identity.csproj & goto exit +:exit \ No newline at end of file diff --git a/src/Services/Identity/nuget.config b/src/Services/Identity/nuget.config new file mode 100644 index 00000000..6ce97590 --- /dev/null +++ b/src/Services/Identity/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Identity/readme.md b/src/Services/Identity/readme.md new file mode 100644 index 00000000..c1fb38ae --- /dev/null +++ b/src/Services/Identity/readme.md @@ -0,0 +1,15 @@ +# Generating Keys +- [JWT Authentication with Asymmetric Encryption using certificates in ASP.NET Core](https://dev.to/eduardstefanescu/jwt-authentication-with-asymmetric-encryption-using-certificates-in-asp-net-core-2o7e) +- [Generate self-signed certificates with the .NET CLI](https://docs.microsoft.com/en-us/dotnet/core/additional-tools/self-signed-certificates-guide) +- [Convert .pem to .crt and .key](https://stackoverflow.com/questions/13732826/convert-pem-to-crt-and-key) +- [How to Generate a Self-Signed Certificate and Private Key using OpenSSL](https://helpcenter.gsx.com/hc/en-us/articles/115015960428-How-to-Generate-a-Self-Signed-Certificate-and-Private-Key-using-OpenSSL) + +``` cmd +openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 +``` + +## Generate A Test Token + +``` cmd +dotnet user-jwts create +``` \ No newline at end of file diff --git a/src/Services/Identity/watch.Dockerfile b/src/Services/Identity/watch.Dockerfile new file mode 100644 index 00000000..2264b531 --- /dev/null +++ b/src/Services/Identity/watch.Dockerfile @@ -0,0 +1,51 @@ + +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilation + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as builder + +WORKDIR /src + +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Identity/Directory.Build.props ./Services/Identity/ + +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity.Api/ ./Services/Identity/FoodDelivery.Services.Identity.Api/ +COPY ./src/Services/Identity/FoodDelivery.Services.Identity/ ./Services/Identity/FoodDelivery.Services.Identity/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Identity/FoodDelivery.Services.Identity.Api/ + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +RUN dotnet watch run FoodDelivery.Services.Identity.Api.csproj --launch-profile Identity.Api.LiveRecompilation diff --git a/src/Services/Orders/Directory.Build.props b/src/Services/Orders/Directory.Build.props new file mode 100644 index 00000000..92d4a126 --- /dev/null +++ b/src/Services/Orders/Directory.Build.props @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Orders/Dockerfile b/src/Services/Orders/Dockerfile new file mode 100644 index 00000000..1dd02b3a --- /dev/null +++ b/src/Services/Orders/Dockerfile @@ -0,0 +1,100 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Orders/Directory.Build.props ./Services/Orders/ + +# TODO: Using wildcard to copy all files in the directory. +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj ./Services/Orders/FoodDelivery.Services.Orders/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj ./Services/Orders/FoodDelivery.Services.Orders.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Orders.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN dotnet restore ./Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders.Api/ ./Services/Orders/FoodDelivery.Services.Orders.Api/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders/ ./Services/Orders/FoodDelivery.Services.Orders/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Orders/FoodDelivery.Services.Orders.Api/ + +RUN dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Orders.Api.dll"] diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj b/src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj new file mode 100644 index 00000000..89524c53 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj @@ -0,0 +1,34 @@ + + + + true + + + + + + + + orders + dev + mcr.microsoft.com/dotnet/aspnet:latest + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/Program.cs b/src/Services/Orders/FoodDelivery.Services.Orders.Api/Program.cs new file mode 100644 index 00000000..204da5f0 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/Program.cs @@ -0,0 +1,86 @@ +using Bogus; +using BuildingBlocks.Core.Extensions.ServiceCollection; +using BuildingBlocks.Core.Web; +using BuildingBlocks.Swagger; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Minimal.Extensions; +using BuildingBlocks.Web.Modules.Extensions; +using FoodDelivery.Services.Orders; +using Spectre.Console; + +AnsiConsole.Write(new FigletText("Orders Service").Centered().Color(Color.FromInt32(new Faker().Random.Int(1, 255)))); + +// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis +// https://benfoster.io/blog/mvc-to-minimal-apis-aspnet-6/ +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider( + (context, options) => + { + var isDevMode = + context.HostingEnvironment.IsDevelopment() + || context.HostingEnvironment.IsTest() + || context.HostingEnvironment.IsStaging(); + + // Handling Captive Dependency Problem + // https://ankitvijay.net/2020/03/17/net-core-and-di-beware-of-captive-dependency/ + // https://levelup.gitconnected.com/top-misconceptions-about-dependency-injection-in-asp-net-core-c6a7afd14eb4 + // https://blog.ploeh.dk/2014/06/02/captive-dependency/ + // https://andrewlock.net/new-in-asp-net-core-3-service-provider-validation/ + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/web-host?view=aspnetcore-7.0&viewFallbackFrom=aspnetcore-2.2#scope-validation + // CreateDefaultBuilder and WebApplicationBuilder in minimal apis sets `ServiceProviderOptions.ValidateScopes` and `ServiceProviderOptions.ValidateOnBuild` to true if the app's environment is Development. + // check dependencies are used in a valid life time scope + options.ValidateScopes = isDevMode; + // validate dependencies on the startup immediately instead of waiting for using the service - Issue with masstransit #85 + // options.ValidateOnBuild = isDevMode; + } +); + +// https://www.talkingdotnet.com/disable-automatic-model-state-validation-in-asp-net-core-2-1/ +builder.Services.Configure(options => +{ + options.SuppressModelStateInvalidFilter = true; +}); + +builder.Services.AddValidatedOptions(); + +// register endpoints +builder.AddMinimalEndpoints(typeof(OrdersMetadata).Assembly); + +/*----------------- Module Services Setup ------------------*/ +builder.AddModulesServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsTest()) +{ + app.Services.ValidateDependencies( + builder.Services, + typeof(OrdersMetadata).Assembly, + Assembly.GetExecutingAssembly() + ); +} + +/*----------------- Module Middleware Setup ------------------*/ +await app.ConfigureModules(); + +// https://thecodeblogger.com/2021/05/27/asp-net-core-web-application-routing-and-endpoint-internals/ +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#routing-basics +// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-7.0#endpoints +// https://stackoverflow.com/questions/57846127/what-are-the-differences-between-app-userouting-and-app-useendpoints +// in .net 6 and above we don't need UseRouting and UseEndpoints but if ordering is important we should write it +// app.UseRouting(); + +/*----------------- Module Routes Setup ------------------*/ +app.MapModulesEndpoints(); + +// automatic discover minimal endpoints +app.MapMinimalEndpoints(); + +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("docker")) +{ + // swagger middleware should register last to discover all endpoints and its versions correctly + app.UseCustomSwagger(); +} + +await app.RunAsync(); diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/Properties/launchSettings.json b/src/Services/Orders/FoodDelivery.Services.Orders.Api/Properties/launchSettings.json new file mode 100644 index 00000000..0d78631a --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/Properties/launchSettings.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemaecommerce.org/launchsettings.json", + "profiles": { + "Orders.Api.Http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:9000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Orders.Api.Https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "https://localhost:9001;http://localhost:9000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Orders.Api.Watch": { + "commandName": "Executable", + "executablePath": "dotnet", + "workingDirectory": "$(ProjectDir)", + "hotReloadEnabled": true, + "hotReloadProfile": "aspnetcore", + "commandLineArgs": "watch -lp Orders.Api.Http", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Orders.Api.LiveRecompilation": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "hotReloadProfile": "aspnetcore", + "launchUrl": "swagger", + "applicationUrl": "http://localhost:9000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.development.json b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.development.json new file mode 100644 index 00000000..f9580005 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.development.json @@ -0,0 +1,15 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + } + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.docker.json b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.docker.json new file mode 100644 index 00000000..5b4ab3e0 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.docker.json @@ -0,0 +1,38 @@ +{ + "AppOptions": { + "Name": "Orders Api", + "Description": "Orders Api", + "ApiAddress": "http://localhost:9000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@mongo:27017", + "DatabaseName": "food-delivery-services-orders" + }, + "PostgresOptions": { + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_orders;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "RabbitMqOptions": { + "Host": "rabbitmq", + "UserName": "guest", + "Password": "guest" + }, + "IdentityApiClientOptions": { + "BaseApiAddress": "http://identity:80", + "UsersEndpoint": "api/v1/identity/users" + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=postgres;Port=5432;Database=food_delivery_services_orders;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.json b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.json new file mode 100644 index 00000000..4c31f200 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.json @@ -0,0 +1,83 @@ +{ + "Serilog": { + "ElasticSearchUrl": "http://localhost:9200", + "SeqUrl": "http://localhost:5341", + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning", + "MassTransit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "AppOptions": { + "Name": "Orders Api", + "Description": "Orders Api", + "ApiAddress": "http://localhost:9000" + }, + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@localhost:27017", + "DatabaseName": "food-delivery-services-orders" + }, + "PostgresOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_orders;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "JwtOptions": { + "SecretKey": "50d14aWf9FrMwc7SOLoz", + "Audience": "food-delivery-api", + "Issuer": "food-delivery-identity", + "TokenLifeTimeSecond": 300, + "CheckRevokedAccessTokens": true + }, + "RabbitMqOptions": { + "Host": "localhost", + "UserName": "guest", + "Password": "guest" + }, + "IdentityApiClientOptions": { + "BaseApiAddress": "http://localhost:7000", + "UsersEndpoint": "api/v1/identity/users" + }, + "PolicyOptions": { + "RetryCount": 3, + "BreakDuration": 30, + "TimeOutDuration": 15 + }, + "EmailOptions": { + "From": "info@my-food-delivery-service.com", + "Enable": true, + "DisplayName": "Food Delivery Application Mail", + "MimeKitOptions": { + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "", + "Password": "" + } + }, + "OpenTelemetryOptions": { + "ZipkinExporterOptions": { + "Endpoint": "http://localhost:9411/api/v2/spans" + }, + "JaegerExporterOptions": { + "AgentHost": "localhost", + "AgentPort": 6831 + } + }, + "MessagePersistenceOptions": { + "Interval": 30, + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_orders;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + }, + "CacheOptions": { + "ExpirationTime": 360 + }, + "HealthOptions": { + "Enabled": false + }, + "ConfigurationFolder": "config-files/" +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.test.json b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.test.json new file mode 100644 index 00000000..6dd8cbac --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders.Api/appsettings.test.json @@ -0,0 +1,19 @@ +{ + "MongoOptions": { + "ConnectionString": "mongodb://admin:admin@localhost:27017", + "DatabaseName": "food-delivery-services-orders-test" + }, + "PostgresOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_orders_test;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseInMemory": false + }, + "IdentityApiClientOptions": { + "BaseApiAddress": "http://localhost:7000", + "UsersEndpoint": "api/v1/identity/users" + }, + "MessagePersistenceOptions": { + "Interval": 5, + "ConnectionString": "Server=localhost;Port=5432;Database=food_delivery_services_orders_test;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs new file mode 100644 index 00000000..ba66741d --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs @@ -0,0 +1,12 @@ +using FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; +using MassTransit; + +namespace FoodDelivery.Services.Orders.Customers.Features.CreatingCustomer.v1.Events.External; + +public class CustomerCreatedConsumer : IConsumer +{ + public Task Consume(ConsumeContext context) + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs new file mode 100644 index 00000000..efccbd1e --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs @@ -0,0 +1,40 @@ +using FoodDelivery.Services.Orders.Customers.Features.CreatingCustomer.v1.Events.External; +using FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace FoodDelivery.Services.Orders.Customers; + +internal static class MassTransitExtensions +{ + internal static void AddCustomerEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) + { + cfg.ReceiveEndpoint( + nameof(CustomerCreatedV1).Underscore(), + re => + { + // turns off default fanout settings + re.ConfigureConsumeTopology = false; + + // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + re.SetQuorumQueue(); + + re.Bind( + $"{nameof(CustomerCreatedV1).Underscore()}.input_exchange", + e => + { + e.RoutingKey = nameof(CustomerCreatedV1).Underscore(); + e.ExchangeType = ExchangeType.Direct; + } + ); + + // https://github.com/MassTransit/MassTransit/discussions/3117 + // https://masstransit-project.com/usage/configuration.html#receive-endpoints + re.ConfigureConsumer(context); + + re.RethrowFaultedMessages(); + } + ); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj b/src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj new file mode 100644 index 00000000..e5d3f283 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj @@ -0,0 +1,41 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Data/EntityConfigurations/OrderEntityTypeConfiguration.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Data/EntityConfigurations/OrderEntityTypeConfiguration.cs new file mode 100644 index 00000000..deeccd8f --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Data/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -0,0 +1,24 @@ +using FoodDelivery.Services.Orders.Orders.Models; +using FoodDelivery.Services.Orders.Shared.Data; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FoodDelivery.Services.Orders.Orders.Data.EntityConfigurations; + +public class OrderEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(nameof(Order).Pluralize().Underscore(), OrdersDbContext.DefaultSchema); + + // ids will use strongly typed-id value converter selector globally + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Id).IsUnique(); + + builder.OwnsOne(x => x.Customer); + + builder.OwnsOne(m => m.Product); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Order.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Order.cs new file mode 100644 index 00000000..902bd779 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Order.cs @@ -0,0 +1,22 @@ +using BuildingBlocks.Core.Domain; +using FoodDelivery.Services.Orders.Orders.ValueObjects; + +namespace FoodDelivery.Services.Orders.Orders.Models; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://github.com/dotnet/efcore/issues/29940 +public class Order : Aggregate +{ + // EF + // this constructor is needed when we have a parameter constructor that has some navigation property classes in the parameters and ef will skip it and try to find other constructor, here default constructor (maybe will fix .net 8) + private Order() { } + + public CustomerInfo Customer { get; private set; } = default!; + public ProductInfo Product { get; private set; } = default!; + + public static Order Create(CustomerInfo customerInfo, ProductInfo productInfo) + { + //TODO: Complete order domain model + return new Order { Customer = customerInfo, Product = productInfo }; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Reads/OrderReadModel.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Reads/OrderReadModel.cs new file mode 100644 index 00000000..a3843085 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/Models/Reads/OrderReadModel.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Orders.Orders.Models.Reads; + +public class OrderReadModel +{ + public Guid Id { get; init; } + public long OrderId { get; init; } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/CustomerInfo.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/CustomerInfo.cs new file mode 100644 index 00000000..16e77430 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/CustomerInfo.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Orders.Orders.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public record CustomerInfo +{ + // EF + private CustomerInfo() { } + + public string Name { get; private set; } = default!; + public long CustomerId { get; private set; } + + public static CustomerInfo Of([NotNull] string? name, long customerId) + { + // validations should be placed here instead of constructor + name.NotBeEmptyOrNull(); + customerId.NotBeNegativeOrZero(); + + return new CustomerInfo { Name = name, CustomerId = customerId }; + } + + public void Deconstruct(out string name, out long customerId) => (name, customerId) = (Name, CustomerId); +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/OrderId.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/OrderId.cs new file mode 100644 index 00000000..3cedd7b9 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/OrderId.cs @@ -0,0 +1,19 @@ +using BuildingBlocks.Abstractions.Domain; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Orders.Orders.ValueObjects; + +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +// https://event-driven.io/en/how_to_validate_business_logic/ +// https://event-driven.io/en/explicit_validation_in_csharp_just_got_simpler/ +public record OrderId : AggregateId +{ + // EF + private OrderId(long value) + : base(value) { } + + // validations should be placed here instead of constructor + public static OrderId Of(long id) => new(id.NotBeNegativeOrZero()); + + public static implicit operator long(OrderId id) => id.Value; +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/ProductInfo.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/ProductInfo.cs new file mode 100644 index 00000000..24d09673 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Orders/ValueObjects/ProductInfo.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using BuildingBlocks.Core.Extensions; + +namespace FoodDelivery.Services.Orders.Orders.ValueObjects; + +// https://ardalis.com/working-with-value-objects/ +// https://learn.microsoft.com/en-us/ef/core/modeling/constructors +public record ProductInfo +{ + // EF + private ProductInfo() { } + + public string Name { get; private set; } = default!; + public long ProductId { get; private set; } + public decimal Price { get; private set; } + + public static ProductInfo Of([NotNull] string? name, long productId, decimal price) + { + return new ProductInfo + { + Name = name.NotBeEmptyOrNull(), + ProductId = productId.NotBeNegativeOrZero(), + Price = price.NotBeNegativeOrZero() + }; + } + + public void Deconstruct(out string name, out long productId, out decimal price) => + (name, productId, price) = (Name, ProductId, Price); +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/OrdersConstants.cs b/src/Services/Orders/FoodDelivery.Services.Orders/OrdersConstants.cs new file mode 100644 index 00000000..cfed7eab --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/OrdersConstants.cs @@ -0,0 +1,10 @@ +namespace FoodDelivery.Services.Orders; + +public class OrdersConstants +{ + public static class Role + { + public const string Admin = "admin"; + public const string User = "user"; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/OrdersMetadata.cs b/src/Services/Orders/FoodDelivery.Services.Orders/OrdersMetadata.cs new file mode 100644 index 00000000..e33db5f0 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/OrdersMetadata.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Orders; + +public class OrdersMetadata { } diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Contracts/IOrdersDbContext.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Contracts/IOrdersDbContext.cs new file mode 100644 index 00000000..0838aff5 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Contracts/IOrdersDbContext.cs @@ -0,0 +1,10 @@ +using BuildingBlocks.Abstractions.Persistence.EfCore; +using Microsoft.EntityFrameworkCore; +using FoodDelivery.Services.Orders.Orders.Models; + +namespace FoodDelivery.Services.Orders.Shared.Contracts; + +public interface IOrdersDbContext : IDbContext +{ + public DbSet Orders { get; } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.Designer.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.Designer.cs new file mode 100644 index 00000000..d0aa2283 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.Designer.cs @@ -0,0 +1,121 @@ +// +using System; +using FoodDelivery.Services.Orders.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Orders.Shared.Data.Migrations.Orders +{ + [DbContext(typeof(OrdersDbContext))] + [Migration("20240719225056_InitialOrdersMigration")] + partial class InitialOrdersMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "uuid-ossp"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Orders.Orders.Models.Order", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_orders"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_orders_id"); + + b.ToTable("orders", "order"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Orders.Orders.Models.Order", b => + { + b.OwnsOne("FoodDelivery.Services.Orders.Orders.ValueObjects.CustomerInfo", "Customer", b1 => + { + b1.Property("OrderId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("customer_customer_id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("customer_name"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders", "order"); + + b1.WithOwner() + .HasForeignKey("OrderId") + .HasConstraintName("fk_orders_orders_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Orders.Orders.ValueObjects.ProductInfo", "Product", b1 => + { + b1.Property("OrderId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_name"); + + b1.Property("Price") + .HasColumnType("numeric") + .HasColumnName("product_price"); + + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("product_product_id"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders", "order"); + + b1.WithOwner() + .HasForeignKey("OrderId") + .HasConstraintName("fk_orders_orders_id"); + }); + + b.Navigation("Customer") + .IsRequired(); + + b.Navigation("Product") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.cs new file mode 100644 index 00000000..e493763b --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/20240719225056_InitialOrdersMigration.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FoodDelivery.Services.Orders.Shared.Data.Migrations.Orders +{ + /// + public partial class InitialOrdersMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "order"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:uuid-ossp", ",,"); + + migrationBuilder.CreateTable( + name: "orders", + schema: "order", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + customer_name = table.Column(type: "text", nullable: false), + customer_customer_id = table.Column(type: "bigint", nullable: false), + product_name = table.Column(type: "text", nullable: false), + product_product_id = table.Column(type: "bigint", nullable: false), + product_price = table.Column(type: "numeric", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "integer", nullable: true), + original_version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_orders", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_orders_id", + schema: "order", + table: "orders", + column: "id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "orders", + schema: "order"); + } + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/OrdersDbContextModelSnapshot.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/OrdersDbContextModelSnapshot.cs new file mode 100644 index 00000000..9dfe4ec9 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/Migrations/Orders/OrdersDbContextModelSnapshot.cs @@ -0,0 +1,118 @@ +// +using System; +using FoodDelivery.Services.Orders.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FoodDelivery.Services.Orders.Shared.Data.Migrations.Orders +{ + [DbContext(typeof(OrdersDbContext))] + partial class OrdersDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "uuid-ossp"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FoodDelivery.Services.Orders.Orders.Models.Order", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("integer") + .HasColumnName("created_by"); + + b.Property("OriginalVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("original_version"); + + b.HasKey("Id") + .HasName("pk_orders"); + + b.HasIndex("Id") + .IsUnique() + .HasDatabaseName("ix_orders_id"); + + b.ToTable("orders", "order"); + }); + + modelBuilder.Entity("FoodDelivery.Services.Orders.Orders.Models.Order", b => + { + b.OwnsOne("FoodDelivery.Services.Orders.Orders.ValueObjects.CustomerInfo", "Customer", b1 => + { + b1.Property("OrderId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("CustomerId") + .HasColumnType("bigint") + .HasColumnName("customer_customer_id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("customer_name"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders", "order"); + + b1.WithOwner() + .HasForeignKey("OrderId") + .HasConstraintName("fk_orders_orders_id"); + }); + + b.OwnsOne("FoodDelivery.Services.Orders.Orders.ValueObjects.ProductInfo", "Product", b1 => + { + b1.Property("OrderId") + .HasColumnType("bigint") + .HasColumnName("id"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("product_name"); + + b1.Property("Price") + .HasColumnType("numeric") + .HasColumnName("product_price"); + + b1.Property("ProductId") + .HasColumnType("bigint") + .HasColumnName("product_product_id"); + + b1.HasKey("OrderId"); + + b1.ToTable("orders", "order"); + + b1.WithOwner() + .HasForeignKey("OrderId") + .HasConstraintName("fk_orders_orders_id"); + }); + + b.Navigation("Customer") + .IsRequired(); + + b.Navigation("Product") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrderReadDbContext.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrderReadDbContext.cs new file mode 100644 index 00000000..89aec899 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrderReadDbContext.cs @@ -0,0 +1,18 @@ +using BuildingBlocks.Persistence.Mongo; +using Humanizer; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using FoodDelivery.Services.Orders.Orders.Models.Reads; + +namespace FoodDelivery.Services.Orders.Shared.Data; + +public class OrderReadDbContext : MongoDbContext +{ + public OrderReadDbContext(IOptions options) + : base(options.Value) + { + Orders = GetCollection(nameof(Orders).Underscore()); + } + + public IMongoCollection Orders { get; } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDataSeeder.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDataSeeder.cs new file mode 100644 index 00000000..1d7ab6aa --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDataSeeder.cs @@ -0,0 +1,13 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Orders.Shared.Data; + +public class OrdersDataSeeder : IDataSeeder +{ + public int Order => 1; + + public Task SeedAllAsync() + { + return Task.CompletedTask; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContext.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContext.cs new file mode 100644 index 00000000..f87f3f87 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContext.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using FoodDelivery.Services.Orders.Orders.Models; +using FoodDelivery.Services.Orders.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Orders.Shared.Data; + +public class OrdersDbContext : EfDbContextBase, IOrdersDbContext +{ + public const string DefaultSchema = "order"; + + public OrdersDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasPostgresExtension(EfConstants.UuidGenerator); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } + + public DbSet Orders => Set(); +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContextDesignFactory.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContextDesignFactory.cs new file mode 100644 index 00000000..b83df8b8 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersDbContextDesignFactory.cs @@ -0,0 +1,9 @@ +using BuildingBlocks.Persistence.EfCore.Postgres; + +namespace FoodDelivery.Services.Orders.Shared.Data; + +public class OrdersDbContextDesignFactory : DbContextDesignFactoryBase +{ + public OrdersDbContextDesignFactory() + : base("PostgresOptions:ConnectionString") { } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersMigrationExecutor.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersMigrationExecutor.cs new file mode 100644 index 00000000..7aed21de --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Data/OrdersMigrationExecutor.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Abstractions.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FoodDelivery.Services.Orders.Shared.Data; + +public class OrdersMigrationExecutor : IMigrationExecutor +{ + private readonly OrdersDbContext _ordersDbContext; + private readonly ILogger _logger; + + public OrdersMigrationExecutor(OrdersDbContext ordersDbContext, ILogger logger) + { + _ordersDbContext = ordersDbContext; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migration worker started"); + + _logger.LogInformation("Updating identity database..."); + + await _ordersDbContext.Database.MigrateAsync(cancellationToken: cancellationToken); + + _logger.LogInformation("identity database Updated"); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/OrdersDbContextExtensions.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/OrdersDbContextExtensions.cs new file mode 100644 index 00000000..b8fa18a2 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/OrdersDbContextExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using FoodDelivery.Services.Orders.Orders.Models; +using FoodDelivery.Services.Orders.Orders.ValueObjects; +using FoodDelivery.Services.Orders.Shared.Data; + +namespace FoodDelivery.Services.Orders.Shared.Extensions; + +public static class OrdersDbContextExtensions +{ + public static ValueTask FindOrderByIdAsync(this OrdersDbContext context, OrderId id) + { + return context.Orders.FindAsync(id); + } + + public static Task ExistsOrderByIdAsync(this OrdersDbContext context, OrderId id) + { + return context.Orders.AnyAsync(x => x.Id == id); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs new file mode 100644 index 00000000..979023d3 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -0,0 +1,141 @@ +using System.Threading.RateLimiting; +using BuildingBlocks.Caching; +using BuildingBlocks.Caching.Behaviours; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Email; +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Integration.MassTransit; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.OpenTelemetry; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Security.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Swagger; +using BuildingBlocks.Validation; +using BuildingBlocks.Validation.Extensions; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Orders.Customers; + +namespace FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationBuilderExtensions; + +internal static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder) + { + builder.Services.AddCore(); + + builder.Services.AddCustomJwtAuthentication(builder.Configuration); + builder.Services.AddCustomAuthorization( + rolePolicies: new List + { + new(OrdersConstants.Role.Admin, new List { OrdersConstants.Role.Admin }), + new(OrdersConstants.Role.User, new List { OrdersConstants.Role.User }) + } + ); + + // https://www.michaco.net/blog/EnvironmentVariablesAndConfigurationInASPNETCoreApps#environment-variables-and-configuration + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#non-prefixed-environment-variables + builder.Configuration.AddEnvironmentVariables("food_delivery_orders_env_"); + + // https://github.com/tonerdo/dotnet-env + DotNetEnv.Env.TraversePath().Load(); + + builder.AddCompression(); + + builder.AddAppProblemDetails(); + + builder.AddCustomSerilog(); + + builder.AddCustomVersioning(); + + builder.AddCustomSwagger(); + + builder.AddCustomCors(); + + builder.Services.AddHttpContextAccessor(); + + builder.AddCustomOpenTelemetry(); + + if (builder.Environment.IsTest() == false) + { + builder.AddCustomHealthCheck(healthChecksBuilder => + { + var postgresOptions = builder.Configuration.BindOptions(); + var rabbitMqOptions = builder.Configuration.BindOptions(); + + postgresOptions.NotBeNull(); + rabbitMqOptions.NotBeNull(); + + healthChecksBuilder + .AddNpgSql( + postgresOptions.ConnectionString, + name: "OrdersService-Postgres-Check", + tags: new[] { "postgres", "database", "infra", "orders-service", "live", "ready" } + ) + .AddRabbitMQ( + rabbitMqOptions.ConnectionString, + name: "OrdersService-RabbitMQ-Check", + timeout: TimeSpan.FromSeconds(3), + tags: new[] { "rabbitmq", "bus", "infra", "orders-service", "live", "ready" } + ); + }); + } + + builder.Services.AddEmailService(builder.Configuration); + + builder.Services.AddCqrs( + pipelines: new[] + { + typeof(LoggingBehavior<,>), + typeof(StreamLoggingBehavior<,>), + typeof(RequestValidationBehavior<,>), + typeof(StreamRequestValidationBehavior<,>), + typeof(StreamCachingBehavior<,>), + typeof(CachingBehavior<,>), + typeof(InvalidateCachingBehavior<,>), + typeof(EfTxBehavior<,>) + } + ); + + builder.Services.AddPostgresMessagePersistence(builder.Configuration); + + // https://blog.maartenballiauw.be/post/2022/09/26/aspnet-core-rate-limiting-middleware.html + builder.Services.AddRateLimiter(options => + { + // rate limiter that limits all to 10 requests per minute, per authenticated username (or hostname if not authenticated) + options.GlobalLimiter = PartitionedRateLimiter.Create( + httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.User.Identity?.Name ?? httpContext.Request.Headers.Host.ToString(), + factory: partition => + new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = 10, + QueueLimit = 0, + Window = TimeSpan.FromMinutes(1) + } + ) + ); + }); + + builder.AddCustomMassTransit( + (context, cfg) => + { + cfg.AddCustomerEndpoints(context); + }, + autoConfigEndpoints: false + ); + + builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); + + builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); + + builder.AddCustomEasyCaching(); + + return builder; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs new file mode 100644 index 00000000..72e091e9 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs @@ -0,0 +1,49 @@ +using BuildingBlocks.Abstractions.Domain.Events.Internal; +using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Persistence.Mongo; +using FoodDelivery.Services.Orders.Shared.Contracts; +using FoodDelivery.Services.Orders.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationBuilderExtensions; + +internal static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddStorage(this WebApplicationBuilder builder) + { + AddPostgresWriteStorage(builder.Services, builder.Configuration); + AddMongoReadStorage(builder.Services, builder.Configuration); + + return builder; + } + + private static void AddPostgresWriteStorage(IServiceCollection services, IConfiguration configuration) + { + if (configuration.GetValue("PostgresOptions.UseInMemory")) + { + services.AddDbContext( + options => options.UseInMemoryDatabase("FoodDelivery.Services.Orders") + ); + + services.TryAddScoped(provider => provider.GetService()!); + services.TryAddScoped(provider => provider.GetService()!); + } + else + { + services.AddPostgresDbContext(configuration); + + // add migrations and seeders dependencies, or we could add seeders inner each modules + services.TryAddScoped(); + services.TryAddScoped(); + } + + services.TryAddScoped(provider => provider.GetRequiredService()); + } + + private static void AddMongoReadStorage(IServiceCollection services, IConfiguration configuration) + { + services.AddMongoDbContext(configuration); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs new file mode 100644 index 00000000..9f185c9c --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Web.Problem; +using Microsoft.AspNetCore.Diagnostics; + +namespace FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationBuilderExtensions; + +internal static partial class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddAppProblemDetails(this WebApplicationBuilder builder) + { + builder.Services.AddCustomProblemDetails(problemDetailsOptions => + { + // customization problem details should go here + problemDetailsOptions.CustomizeProblemDetails = problemDetailContext => + { + // with help of capture exception middleware for capturing actual exception + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + if (problemDetailContext.HttpContext.Features.Get() is { } exceptionFeature) + { } + }; + }); + + return builder; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs new file mode 100644 index 00000000..cad882cf --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs @@ -0,0 +1,68 @@ +using BuildingBlocks.HealthCheck; +using BuildingBlocks.Logging; +using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; +using BuildingBlocks.Web.Extensions; +using BuildingBlocks.Web.Middlewares.CaptureExceptionMiddleware; +using BuildingBlocks.Web.Middlewares.RequestLogContextMiddleware; +using FoodDelivery.Services.Catalogs; +using Serilog; + +namespace FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationExtensions; + +public static partial class WebApplicationExtensions +{ + public static async Task UseInfrastructure(this WebApplication app) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling + // Does nothing if a response body has already been provided. when our next `DeveloperExceptionMiddleware` is written response for exception (in dev mode) when we back to `ExceptionHandlerMiddlewareImpl` because `context.Response.HasStarted` it doesn't do anything + // By default `ExceptionHandlerMiddlewareImpl` middleware register original exceptions with `IExceptionHandlerFeature` feature, we don't have this in `DeveloperExceptionPageMiddleware` and we should handle it with a middleware like `CaptureExceptionMiddleware` + // Just for handling exceptions in production mode + // https://github.com/dotnet/aspnetcore/pull/26567 + app.UseExceptionHandler(new ExceptionHandlerOptions { AllowStatusCode404Response = true }); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment() || app.Environment.IsTest()) + { + // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/handle-errrors + app.UseDeveloperExceptionPage(); + + // https://github.com/dotnet/aspnetcore/issues/4765 + // https://github.com/dotnet/aspnetcore/pull/47760 + // .net 8 will add `IExceptionHandlerFeature`in `DisplayExceptionContent` and `SetExceptionHandlerFeatures` methods `DeveloperExceptionPageMiddlewareImpl` class, exact functionality of CaptureException + // bet before .net 8 preview 5 we should add `IExceptionHandlerFeature` manually with our `UseCaptureException` + app.UseCaptureException(); + } + + // this middleware should be first middleware + // request logging just log in information level and above as default + app.UseSerilogRequestLogging(opts => + { + opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest; + + // this level wil use for request logging + // https://andrewlock.net/using-serilog-aspnetcore-in-asp-net-core-3-excluding-health-check-endpoints-from-serilog-request-logging/#customising-the-log-level-used-for-serilog-request-logs + opts.GetLevel = LogEnricher.GetLogLevel; + }); + + app.UseRequestLogContextMiddleware(); + + app.UseCustomCors(); + + app.UseAuthentication(); + app.UseAuthorization(); + + await app.UsePostgresPersistenceMessage(app.Logger); + + await app.MigrateDatabases(); + + app.UseCustomRateLimit(); + + if (app.Environment.IsTest() == false) + app.UseCustomHealthCheck(); + + // Configure the prometheus endpoint for scraping metrics + // NOTE: This should only be exposed on an internal port! + // .RequireHost("*:9100"); + app.MapPrometheusScrapingEndpoint(); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Migration.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Migration.cs new file mode 100644 index 00000000..6040cbd9 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationExtensions/Migration.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Abstractions.Persistence; + +namespace FoodDelivery.Services.Catalogs; + +public static partial class WebApplicationExtensions +{ + public static async Task MigrateDatabases(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var migrationManager = scope.ServiceProvider.GetRequiredService(); + + await migrationManager.ExecuteAsync(CancellationToken.None); + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/SharedModulesConfiguration.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/SharedModulesConfiguration.cs new file mode 100644 index 00000000..7f70ae89 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/SharedModulesConfiguration.cs @@ -0,0 +1,51 @@ +using BuildingBlocks.Abstractions.Web.Module; +using BuildingBlocks.Core; +using FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationBuilderExtensions; +using FoodDelivery.Services.Orders.Shared.Extensions.WebApplicationExtensions; + +namespace FoodDelivery.Services.Orders.Shared; + +public class SharedModulesConfiguration : ISharedModulesConfiguration +{ + public const string OrderModulePrefixUri = "api/v{version:apiVersion}/orders"; + + public WebApplicationBuilder AddSharedModuleServices(WebApplicationBuilder builder) + { + builder.AddInfrastructure(); + + builder.AddStorage(); + + return builder; + } + + public async Task ConfigureSharedModule(WebApplication app) + { + await app.UseInfrastructure(); + + ServiceActivator.Configure(app.Services); + + return app; + } + + public IEndpointRouteBuilder MapSharedModuleEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints + .MapGet( + "/", + (HttpContext context) => + { + var requestId = context.Request.Headers.TryGetValue( + "X-Request-InternalCommandId", + out var requestIdHeader + ) + ? requestIdHeader.FirstOrDefault() + : string.Empty; + + return $"Orders Service Apis, RequestId: {requestId}"; + } + ) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/readme.md b/src/Services/Orders/FoodDelivery.Services.Orders/readme.md new file mode 100644 index 00000000..6f0346c5 --- /dev/null +++ b/src/Services/Orders/FoodDelivery.Services.Orders/readme.md @@ -0,0 +1,6 @@ +#### Migration Scripts + +```bash +dotnet ef migrations add InitialOrdersMigration -o Shared/Data/Migrations/Orders -c OrdersDbContext +dotnet ef database update -c OrdersDbContext +``` diff --git a/src/Services/Orders/dev.Dockerfile b/src/Services/Orders/dev.Dockerfile new file mode 100644 index 00000000..3a187c44 --- /dev/null +++ b/src/Services/Orders/dev.Dockerfile @@ -0,0 +1,110 @@ +# Using the base image of the Dockerfile for debugging can be more efficient because you don't need to build the entire application from scratch. Instead, you can reuse the already-built layers and add debugging tools and configurations as needed. This can save time and resources, especially if your application is large or complex. +# On the other hand, doing a full build for debugging can ensure that the debugging environment is identical to the production environment. This can help catch issues that may not surface in a modified version of the image, and provide a more accurate representation of the production environment. However, this approach can be slower and require more resources. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +#https://swimburger.net/blog/dotnet/how-to-get-aspdotnet-core-server-urls +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilat +#https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments +EXPOSE 80 +EXPOSE 443 +ENV ASPNETCORE_URLS http://*:80;https://*:443 +ENV ASPNETCORE_ENVIRONMENT docker + +# # https://code.visualstudio.com/docs/containers/troubleshooting#_running-as-a-nonroot-user +# # https://baeldung.com/ops/root-user-password-docker-container +# # https://stackoverflow.com/questions/52070171/whats-the-default-user-for-docker-exec +# # https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 +# # Creates a non-root user with an explicit UID and adds permission to access the /app folder +# # if we don't define a user container will use root user +# RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app +# USER appuser + +FROM mcr.microsoft.com/dotnet/sdk:8.0 as build +WORKDIR /src + +# path are related to build context, here for us build context is root folder +# https://docs.docker.com/build/building/context/ +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Orders/Directory.Build.props ./Services/Orders/ + +# TODO: Using wildcard to copy all files in the directory. +# https://docs.docker.com/build/cache/#order-your-layers +# with any changes in csproj files all downstream layer will rebuil, so dotnet restore will execute again +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +COPY ./src/Services/Orders/FoodDelivery.Services.Orders/FoodDelivery.Services.Orders.csproj ./Services/Orders/FoodDelivery.Services.Orders/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj ./Services/Orders/FoodDelivery.Services.Orders.Api/ +COPY ./src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj ./Services/Shared/FoodDelivery.Services.Shared/ + +# https://docs.docker.com/build/cache/ +# https://docs.docker.com/build/cache/#order-your-layers +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache +# https://github.com/dotnet/dotnet-docker/issues/3353 +# https://stackoverflow.com/questions/69464184/using-docker-buildkit-mount-type-cache-for-caching-nuget-packages-for-net-5-d +# https://pythonspeed.com/articles/docker-cache-pip-downloads/ +# When we have a chnage in a layer that layer and all subsequent layer will rebuild again +# when installing packages, we don’t always need to fetch all of our packages from the internet each time. if we have any package update on `FoodDelivery.Services.Orders.Api.csproj` this layer will rebuild but it don't download all packages again, it just download new packages and for exisitng one uses mount cache +RUN --mount=type=cache,id=orders_nuget,target=/root/.nuget/packages \ + dotnet restore ./Services/Orders/FoodDelivery.Services.Orders.Api/FoodDelivery.Services.Orders.Api.csproj + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders.Api/ ./Services/Orders/FoodDelivery.Services.Orders.Api/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders/ ./Services/Orders/FoodDelivery.Services.Orders/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Orders/FoodDelivery.Services.Orders.Api/ + +RUN --mount=type=cache,id=orders_nuget,target=/root/.nuget/packages\ + dotnet build -c Release --no-restore + +FROM build AS publish +# Publish project to output folder and no build and restore, as we did it already +# https://stackoverflow.com/questions/5457095/release-generating-pdb-files-why +# pdbs also generate for release mode (pdbonly) so vsdb can use it for debugging for debug mode its default is (full) +RUN --mount=type=cache,id=orders_nuget,target=/root/.nuget/packages\ + dotnet publish -c Release --no-build --no-restore -o /app/publish + +FROM base AS final +# Setup working directory for the project +WORKDIR /app +COPY --from=publish /app/publish . + +# for debug mode we change entrypoint with '--entrypoint' in 'docker run' for prevent runing application in this stage because we want to run container app with debugger launcher +#https://docs.docker.com/engine/reference/run/#entrypoint-default-command-to-execute-at-runtime +#https://oprea.rocks/blog/how-to-properly-override-the-entrypoint-using-docker-run + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes.. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +#https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/ +# when we `run` app `dll`, inner `api project` working directory (will resolve to current working directory for app) that contains appsetings.json files or inner `bin directory` because when run app dll in this directory `app working directory` and `current working directory` will be set bin and because appsettings.json are there, so app can find this `appsettings.json` files in current working directory but if we run app dll outside this directories app current working directory will be changed, and it can't find `appsettings.json` files in current working directory, so we should explicitly specify working dir in to `bin` or `app project` folder, this problem doesn't exist for `.csproj files` and their working dir always resolve `correctly` +# in this layer we don't have nugets so we can use mounted volume in `docker run` or `docker-compose up` for this entrypoint when docker container will be run for the `host` with --mount type=bind,source=${env:USERPROFILE}\\.nuget\\packages,destination=/root/.nuget/packages,readonly, for example dotnet --additionalProbingPath /root/nuget/packages --additionalProbingPath ~/.nuget/packages +ENTRYPOINT ["dotnet", "FoodDelivery.Services.Orders.Api.dll"] diff --git a/src/Services/Orders/nuget.config b/src/Services/Orders/nuget.config new file mode 100644 index 00000000..6ce97590 --- /dev/null +++ b/src/Services/Orders/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Orders/watch.Dockerfile b/src/Services/Orders/watch.Dockerfile new file mode 100644 index 00000000..85e7a700 --- /dev/null +++ b/src/Services/Orders/watch.Dockerfile @@ -0,0 +1,49 @@ +#https://tymisko.hashnode.dev/developing-aspnet-core-apps-in-docker-live-recompilation +FROM mcr.microsoft.com/dotnet/sdk:8.0 as builder + +WORKDIR /src + +COPY ./.editorconfig ./ +COPY ./nuget.config ./ + +COPY ./src/Directory.Build.props ./ +COPY ./src/Directory.Build.targets ./ +COPY ./src/Directory.Packages.props ./ +COPY ./src/Packages.props ./ +COPY ./src/Services/Orders/Directory.Build.props ./Services/Orders/ + +# TODO: Using wildcard to copy all files in the directory. +COPY ./src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj ./BuildingBlocks/BuildingBlocks.Abstractions/ +COPY ./src/BuildingBlocks/BuildingBlocks.Core/BuildingBlocks.Core.csproj ./BuildingBlocks/BuildingBlocks.Core/ +COPY ./src/BuildingBlocks/BuildingBlocks.Caching/BuildingBlocks.Caching.csproj ./BuildingBlocks/BuildingBlocks.Caching/ +COPY ./src/BuildingBlocks/BuildingBlocks.Email/BuildingBlocks.Email.csproj ./BuildingBlocks/BuildingBlocks.Email/ +COPY ./src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/BuildingBlocks.Integration.MassTransit.csproj ./BuildingBlocks/BuildingBlocks.Integration.MassTransit/ +COPY ./src/BuildingBlocks/BuildingBlocks.Logging/BuildingBlocks.Logging.csproj ./BuildingBlocks/BuildingBlocks.Logging/ +COPY ./src/BuildingBlocks/BuildingBlocks.HealthCheck/BuildingBlocks.HealthCheck.csproj ./BuildingBlocks/BuildingBlocks.HealthCheck/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/BuildingBlocks.Persistence.EfCore.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/BuildingBlocks.Persistence.Mongo.csproj ./BuildingBlocks/BuildingBlocks.Persistence.Mongo/ +COPY ./src/BuildingBlocks/BuildingBlocks.Resiliency/BuildingBlocks.Resiliency.csproj ./BuildingBlocks/BuildingBlocks.Resiliency/ +COPY ./src/BuildingBlocks/BuildingBlocks.Security/BuildingBlocks.Security.csproj ./BuildingBlocks/BuildingBlocks.Security/ +COPY ./src/BuildingBlocks/BuildingBlocks.Swagger/BuildingBlocks.Swagger.csproj ./BuildingBlocks/BuildingBlocks.Swagger/ +COPY ./src/BuildingBlocks/BuildingBlocks.Validation/BuildingBlocks.Validation.csproj ./BuildingBlocks/BuildingBlocks.Validation/ +COPY ./src/BuildingBlocks/BuildingBlocks.Web/BuildingBlocks.Web.csproj ./BuildingBlocks/BuildingBlocks.Web/ +COPY ./src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/BuildingBlocks.Messaging.Persistence.Postgres.csproj ./BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/ +COPY ./src/BuildingBlocks/BuildingBlocks.OpenTelemetry/BuildingBlocks.OpenTelemetry.csproj ./BuildingBlocks/BuildingBlocks.OpenTelemetry/ + +# Copy project files +COPY ./src/BuildingBlocks/ ./BuildingBlocks/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders.Api/ ./Services/Orders/FoodDelivery.Services.Orders.Api/ +COPY ./src/Services/Orders/FoodDelivery.Services.Orders/ ./Services/Orders/FoodDelivery.Services.Orders/ +COPY ./src/Services/Shared/ ./Services/Shared/ + +WORKDIR /src/Services/Orders/FoodDelivery.Services.Orders.Api/ + +# https://learn.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-7.0#dotnet-watch-configuration +# https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-3.1#watch-for-changes +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch?WT.mc_id=DOP-MVP-5001942#environment-variables +# Some file systems, such as Docker containers and network shares, may not reliably send change notifications. Set the DOTNET_USE_POLLING_FILE_WATCHER environment variable to 1 or true to poll the file system for changes every four seconds +# If set to "1" or "true", dotnet watch uses a polling file watcher instead of CoreFx's FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes. +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 + +RUN dotnet watch run FoodDelivery.Services.Orders.Api.csproj --launch-profile Orders.Api.LiveRecompilation + diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/FoodDelivery.Services.Pricing.Api.csproj b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/FoodDelivery.Services.Pricing.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/FoodDelivery.Services.Pricing.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Program.cs b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Properties/launchSettings.json b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Properties/launchSettings.json new file mode 100644 index 00000000..9a4b8f25 --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:48276", + "sslPort": 44340 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5137", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7197;http://localhost:5137", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.Development.json b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.json b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing/Class1.cs b/src/Services/Pricing/FoodDelivery.Services.Pricing/Class1.cs new file mode 100644 index 00000000..2dbf83b7 --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Pricing; + +public class Class1 { } diff --git a/src/Services/Pricing/FoodDelivery.Services.Pricing/FoodDelivery.Services.Pricing.csproj b/src/Services/Pricing/FoodDelivery.Services.Pricing/FoodDelivery.Services.Pricing.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Pricing/FoodDelivery.Services.Pricing/FoodDelivery.Services.Pricing.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Pricing/readme.md b/src/Services/Pricing/readme.md new file mode 100644 index 00000000..14c363ae --- /dev/null +++ b/src/Services/Pricing/readme.md @@ -0,0 +1,2 @@ +# Pricing Microservice +For handling products and foods prices and also their discounts \ No newline at end of file diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/FoodDelivery.Services.Recommendations.Api.csproj b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/FoodDelivery.Services.Recommendations.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/FoodDelivery.Services.Recommendations.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Program.cs b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Properties/launchSettings.json b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Properties/launchSettings.json new file mode 100644 index 00000000..475794fe --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:27625", + "sslPort": 44305 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5167", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7174;http://localhost:5167", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.Development.json b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.json b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations/Class1.cs b/src/Services/Recommendations/FoodDelivery.Services.Recommendations/Class1.cs new file mode 100644 index 00000000..01fb7d35 --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Recommendations; + +public class Class1 { } diff --git a/src/Services/Recommendations/FoodDelivery.Services.Recommendations/FoodDelivery.Services.Recommendations.csproj b/src/Services/Recommendations/FoodDelivery.Services.Recommendations/FoodDelivery.Services.Recommendations.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Recommendations/FoodDelivery.Services.Recommendations/FoodDelivery.Services.Recommendations.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Controllers/WeatherForecastController.cs b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..faccff7f --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Controllers/WeatherForecastController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; + +namespace FoodDelivery.Services.Restaurants.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + return Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + } + ) + .ToArray(); + } +} diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/FoodDelivery.Services.Restaurants.Api.csproj b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/FoodDelivery.Services.Restaurants.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/FoodDelivery.Services.Restaurants.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Program.cs b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Program.cs new file mode 100644 index 00000000..210fe058 --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Program.cs @@ -0,0 +1,26 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Properties/launchSettings.json b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Properties/launchSettings.json new file mode 100644 index 00000000..76d428b4 --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:33049", + "sslPort": 44372 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5242", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7023;http://localhost:5242", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/WeatherForecast.cs b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/WeatherForecast.cs new file mode 100644 index 00000000..1558992c --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace FoodDelivery.Services.Restaurants.Api; + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } +} diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.Development.json b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.json b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants/Class1.cs b/src/Services/Restaurants/FoodDelivery.Services.Restaurants/Class1.cs new file mode 100644 index 00000000..f2c0f550 --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Restaurants; + +public class Class1 { } diff --git a/src/Services/Restaurants/FoodDelivery.Services.Restaurants/FoodDelivery.Services.Restaurants.csproj b/src/Services/Restaurants/FoodDelivery.Services.Restaurants/FoodDelivery.Services.Restaurants.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Restaurants/FoodDelivery.Services.Restaurants/FoodDelivery.Services.Restaurants.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/FoodDelivery.Services.Reviews.Api.csproj b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/FoodDelivery.Services.Reviews.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/FoodDelivery.Services.Reviews.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Program.cs b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Properties/launchSettings.json b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Properties/launchSettings.json new file mode 100644 index 00000000..8f3e1a96 --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:11022", + "sslPort": 44379 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5025", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7297;http://localhost:5025", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.Development.json b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.json b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews/Class1.cs b/src/Services/Reviews/FoodDelivery.Services.Reviews/Class1.cs new file mode 100644 index 00000000..4cb07344 --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Reviews; + +public class Class1 { } diff --git a/src/Services/Reviews/FoodDelivery.Services.Reviews/FoodDelivery.Services.Reviews.csproj b/src/Services/Reviews/FoodDelivery.Services.Reviews/FoodDelivery.Services.Reviews.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Reviews/FoodDelivery.Services.Reviews/FoodDelivery.Services.Reviews.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Search/FoodDelivery.Services.Search.Api/FoodDelivery.Services.Search.Api.csproj b/src/Services/Search/FoodDelivery.Services.Search.Api/FoodDelivery.Services.Search.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search.Api/FoodDelivery.Services.Search.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Search/FoodDelivery.Services.Search.Api/Program.cs b/src/Services/Search/FoodDelivery.Services.Search.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Search/FoodDelivery.Services.Search.Api/Properties/launchSettings.json b/src/Services/Search/FoodDelivery.Services.Search.Api/Properties/launchSettings.json new file mode 100644 index 00000000..8ebac4bc --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:63015", + "sslPort": 44325 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5189", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7063;http://localhost:5189", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.Development.json b/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.json b/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Search/FoodDelivery.Services.Search/Class1.cs b/src/Services/Search/FoodDelivery.Services.Search/Class1.cs new file mode 100644 index 00000000..7296a1da --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Search; + +public class Class1 { } diff --git a/src/Services/Search/FoodDelivery.Services.Search/FoodDelivery.Services.Search.csproj b/src/Services/Search/FoodDelivery.Services.Search/FoodDelivery.Services.Search.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Search/FoodDelivery.Services.Search/FoodDelivery.Services.Search.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductCreatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductCreatedV1.cs new file mode 100644 index 00000000..9a6b719c --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductCreatedV1.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; + +public record ProductCreatedV1(long Id, string Name, long CategoryId, string CategoryName, int Stock) : IntegrationEvent +{ + /// + /// ProductCreatedV1 with in-line validation. + /// + /// + /// + /// + /// + /// + /// + public static ProductCreatedV1 Of(long id, string? name, long categoryId, string? categoryName, int stock) + { + id.NotBeNegativeOrZero(); + name.NotBeNullOrWhiteSpace(); + categoryId.NotBeNegativeOrZero(); + categoryName.NotBeNullOrWhiteSpace(); + stock.NotBeNegativeOrZero(); + + return new ProductCreatedV1(id, name, categoryId, categoryName, stock); + } +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockDebitedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockDebitedV1.cs new file mode 100644 index 00000000..97fc83d0 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockDebitedV1.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; + +public record ProductStockDebitedV1(long ProductId, int NewStock, int DebitedQuantity) : IntegrationEvent +{ + /// + /// ProductStockDebitedV1 with in-line validation. + /// + /// + /// + /// + /// + public static ProductStockDebitedV1 Of(long productId, int newStock, int debitedQuantity) + { + productId.NotBeNegativeOrZero(); + newStock.NotBeNegativeOrZero(); + debitedQuantity.NotBeNegativeOrZero(); + + return new ProductStockDebitedV1(productId, newStock, debitedQuantity); + } +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockReplenishedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockReplenishedV1.cs new file mode 100644 index 00000000..53bba211 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductStockReplenishedV1.cs @@ -0,0 +1,23 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; + +public record ProductStockReplenishedV1(long ProductId, int NewStock, int ReplenishedQuantity) : IntegrationEvent +{ + /// + /// ProductStockReplenishedV1 with in-line validation. + /// + /// + /// + /// + /// + public static ProductStockReplenishedV1 Of(long productId, int newStock, int replenishedQuantity) + { + productId.NotBeNegativeOrZero(); + newStock.NotBeNegativeOrZero(); + replenishedQuantity.NotBeNegativeOrZero(); + + return new ProductStockReplenishedV1(productId, newStock, replenishedQuantity); + } +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductUpdatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductUpdatedV1.cs new file mode 100644 index 00000000..b8ddb9f3 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Products/Events/v1/Integration/ProductUpdatedV1.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Products.Events.v1.Integration; + +public record ProductUpdatedV1(long Id, string Name, long CategoryId, string CategoryName, int Stock) : IntegrationEvent +{ + /// + /// ProductUpdatedV1 with in-line validation. + /// + /// + /// + /// + /// + /// + /// + public static ProductUpdatedV1 Of(long id, string? name, long categoryId, string? categoryName, int stock) + { + id.NotBeNegativeOrZero(); + name.NotBeNullOrWhiteSpace(); + categoryId.NotBeNegativeOrZero(); + categoryName.NotBeNullOrWhiteSpace(); + stock.NotBeNegativeOrZero(); + + return new ProductUpdatedV1(id, name, categoryId, categoryName, stock); + } +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierCreatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierCreatedV1.cs new file mode 100644 index 00000000..150beedb --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierCreatedV1.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration; + +public record SupplierCreatedV1(long Id, string Name) : IntegrationEvent; diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierDeletedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierDeletedV1.cs new file mode 100644 index 00000000..bb449b5d --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierDeletedV1.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration; + +public record SupplierDeletedV1(long Id) : IntegrationEvent; diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierUpdatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierUpdatedV1.cs new file mode 100644 index 00000000..15c6615d --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Catalogs/Suppliers/Events/v1/Integration/SupplierUpdatedV1.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Catalogs.Suppliers.Events.v1.Integration; + +public record SupplierUpdatedV1(long Id, string Name) : IntegrationEvent; diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerCreatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerCreatedV1.cs new file mode 100644 index 00000000..d92cb94e --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerCreatedV1.cs @@ -0,0 +1,14 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; + +public record CustomerCreatedV1(long CustomerId) : IntegrationEvent +{ + /// + /// CustomerCreatedV1 with in-line validation + /// + /// + /// + public static CustomerCreatedV1 Of(long customerId) => new CustomerCreatedV1(customerId.NotBeNegativeOrZero()); +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerUpdatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerUpdatedV1.cs new file mode 100644 index 00000000..08573d52 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/Customers/Events/v1/Integration/CustomerUpdatedV1.cs @@ -0,0 +1,66 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; + +public record CustomerUpdatedV1( + long Id, + string FirstName, + string LastName, + string Email, + string PhoneNumber, + Guid IdentityId, + DateTime CreatedAt, + DateTime? BirthDate = null, + string? Nationality = null, + string? Address = null +) : IntegrationEvent +{ + /// + /// CustomerUpdatedV1 with in-line validation. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static CustomerUpdatedV1 Of( + long id, + string? firstName, + string? lastName, + string? email, + string? phoneNumber, + Guid identityId, + DateTime createdAt, + DateTime? birthDate, + string? nationality, + string? address + ) + { + id.NotBeNegativeOrZero(); + firstName.NotBeNullOrWhiteSpace(); + lastName.NotBeNullOrWhiteSpace(); + email.NotBeNullOrWhiteSpace().NotBeInvalidEmail(); + phoneNumber.NotBeNullOrWhiteSpace(); + identityId.NotBeEmpty(); + + return new CustomerUpdatedV1( + id, + firstName, + lastName, + email, + phoneNumber, + identityId, + createdAt, + birthDate, + nationality, + address + ); + } +} diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Customers/RestockSubscriptions/Events/v1/Integration/RestockSubscriptionCreatedV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/RestockSubscriptions/Events/v1/Integration/RestockSubscriptionCreatedV1.cs new file mode 100644 index 00000000..13ad1908 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Customers/RestockSubscriptions/Events/v1/Integration/RestockSubscriptionCreatedV1.cs @@ -0,0 +1,5 @@ +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Customers.RestockSubscriptions.Events.v1.Integration; + +public record RestockSubscriptionCreatedV1(long CustomerId, string? Email) : IntegrationEvent; diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj b/src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj new file mode 100644 index 00000000..e56d152e --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/FoodDelivery.Services.Shared.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Services/Shared/FoodDelivery.Services.Shared/Identity/Users/Events/v1/Integration/UserRegisteredV1.cs b/src/Services/Shared/FoodDelivery.Services.Shared/Identity/Users/Events/v1/Integration/UserRegisteredV1.cs new file mode 100644 index 00000000..f3966fa8 --- /dev/null +++ b/src/Services/Shared/FoodDelivery.Services.Shared/Identity/Users/Events/v1/Integration/UserRegisteredV1.cs @@ -0,0 +1,47 @@ +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging; + +namespace FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; + +public record UserRegisteredV1( + Guid IdentityId, + string Email, + string PhoneNumber, + string UserName, + string FirstName, + string LastName, + IEnumerable? Roles +) : IntegrationEvent +{ + /// + /// UserRegisteredV1 with in-line validation. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static UserRegisteredV1 Of( + Guid identityId, + string? email, + string? phoneNumber, + string? userName, + string? firstName, + string? lastName, + IEnumerable? roles + ) + { + return new UserRegisteredV1( + identityId.NotBeEmpty(), + email.NotBeEmptyOrNull().NotBeInvalidEmail(), + phoneNumber.NotBeEmptyOrNull(), + userName.NotBeEmptyOrNull(), + firstName.NotBeEmptyOrNull(), + lastName.NotBeEmptyOrNull(), + roles + ); + } +} diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/FoodDelivery.Services.Shippings.Api.csproj b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/FoodDelivery.Services.Shippings.Api.csproj new file mode 100644 index 00000000..0eca8e8b --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/FoodDelivery.Services.Shippings.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Program.cs b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Program.cs new file mode 100644 index 00000000..9237b156 --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Program.cs @@ -0,0 +1,59 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching" +}; + +app.MapGet( + "/weatherforecast", + () => + { + var forecast = Enumerable + .Range(1, 5) + .Select( + index => + new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + ) + ) + .ToArray(); + return forecast; + } + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Properties/launchSettings.json b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Properties/launchSettings.json new file mode 100644 index 00000000..bd078a48 --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:7009", + "sslPort": 44360 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7020;http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.Development.json b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.json b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings/Class1.cs b/src/Services/Shippings/FoodDelivery.Services.Shippings/Class1.cs new file mode 100644 index 00000000..43730b2d --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings/Class1.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Shippings; + +public class Class1 { } diff --git a/src/Services/Shippings/FoodDelivery.Services.Shippings/FoodDelivery.Services.Shippings.csproj b/src/Services/Shippings/FoodDelivery.Services.Shippings/FoodDelivery.Services.Shippings.csproj new file mode 100644 index 00000000..35e3d842 --- /dev/null +++ b/src/Services/Shippings/FoodDelivery.Services.Shippings/FoodDelivery.Services.Shippings.csproj @@ -0,0 +1,2 @@ + + diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateFactoryTests.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateFactoryTests.cs new file mode 100644 index 00000000..8afbca53 --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateFactoryTests.cs @@ -0,0 +1,24 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.EventSourcing; +using FluentAssertions; + +namespace BuildingBlocks.Core.UnitTests; + +public class AggregateFactoryTests +{ + [Fact] + public void Create_ShouldReturnInstanceOfAggregateRoot() + { + var aggregate = AggregateFactory.CreateAggregate(); + + aggregate.Should().NotBeNull(); + aggregate.Should().BeOfType(); + } + + private class ShoppingCart : EventSourcedAggregate + { + public Guid ClientId { get; private set; } + public IList Products { get; private set; } = new List(); + public DateTime? ConfirmedAt { get; private set; } + } +} diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateTests.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateTests.cs new file mode 100644 index 00000000..62af4061 --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/AggregateTests.cs @@ -0,0 +1,138 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.Events.Internal; +using FluentAssertions; + +namespace BuildingBlocks.Core.UnitTests; + +public class AggregateTests +{ + [Fact] + public void has_uncommitted_event_should_return_true_when_there_is_an_uncommitted_event() + { + var clientId = Guid.NewGuid(); + + var aggregate = ShoppingCart.Create(clientId); + + aggregate.HasUncommittedDomainEvents().Should().BeTrue(); + } + + [Fact] + public void add_domain_events_should_add_correct_events_to_uncommitted_domain_events() + { + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var aggregate = ShoppingCart.Create(clientId); + aggregate.AddItem(productId); + aggregate.Confirm(); + + aggregate.GetUncommittedDomainEvents().Should().HaveCount(3); + + aggregate.GetUncommittedDomainEvents().Last().Should().BeOfType(); + } + + [Fact] + public void mark_events_as_committed_should_remove_events_from_uncommitted_domain_events() + { + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var aggregate = ShoppingCart.Create(clientId); + aggregate.AddItem(productId); + aggregate.Confirm(); + + aggregate.MarkUncommittedDomainEventAsCommitted(); + + aggregate.GetUncommittedDomainEvents().Should().HaveCount(0); + } + + [Fact] + public void flush_uncommitted_should_return_all_uncommitted_event_and_clear_this_list() + { + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var shoppingCard = ShoppingCart.Create(clientId); + shoppingCard.AddItem(productId); + shoppingCard.Confirm(); + + var events = shoppingCard.GetUncommittedDomainEvents(); + events.Count.Should().Be(3); + + shoppingCard.MarkUncommittedDomainEventAsCommitted(); + events.Count.Should().Be(3); + shoppingCard.HasUncommittedDomainEvents().Should().BeFalse(); + + events.Last().Should().BeOfType(); + events.Last().AggregateSequenceNumber.Should().Be(2); + ((Guid)events.Last().AggregateId).Should().Be(shoppingCard.Id); + + shoppingCard.OriginalVersion.Should().Be(0); + } + + # region Models + + private record ShoppingCartInitialized(Guid ShoppingCartId, Guid ClientId) : DomainEvent; + + private record ProductItemAddedToShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ProductItemRemovedFromShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ShoppingCartConfirmed(Guid ShoppingCartId, DateTime ConfirmedAt) : DomainEvent; + + private enum ShoppingCartStatus + { + Pending = 1, + Confirmed = 2, + Cancelled = 4 + } + + private class ShoppingCart : Aggregate + { + private List _products = new(); + + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IReadOnlyList Products => _products.AsReadOnly(); + public DateTime? ConfirmedAt { get; private set; } + + public static ShoppingCart Create(Guid clientId) + { + var shoppingCart = new ShoppingCart + { + ClientId = clientId, + Id = Guid.NewGuid(), + _products = new List(), + Status = ShoppingCartStatus.Pending + }; + + shoppingCart.AddDomainEvents(new ShoppingCartInitialized(shoppingCart.Id, shoppingCart.ClientId)); + + return shoppingCart; + } + + public void AddItem(Guid productId) + { + _products.Add(productId); + + AddDomainEvents(new ProductItemAddedToShoppingCart(Id, productId)); + } + + public void RemoveItem(Guid productId) + { + _products.Remove(productId); + + AddDomainEvents(new ProductItemRemovedFromShoppingCart(Id, productId)); + } + + public void Confirm() + { + ConfirmedAt = DateTime.Now; + Status = ShoppingCartStatus.Confirmed; + + AddDomainEvents(new ShoppingCartConfirmed(Id, DateTime.Now)); + } + } + + # endregion +} diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/BuildingBlocks.Core.UnitTests.csproj b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/BuildingBlocks.Core.UnitTests.csproj new file mode 100644 index 00000000..b755c954 --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/BuildingBlocks.Core.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + + + + + diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/EventSourcedAggregateTests.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/EventSourcedAggregateTests.cs new file mode 100644 index 00000000..c1ebb65c --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/EventSourcedAggregateTests.cs @@ -0,0 +1,203 @@ +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.EventSourcing; +using FluentAssertions; + +namespace BuildingBlocks.Core.UnitTests; + +public class EventSourcedAggregateTests +{ + [Fact] + public void when_should_get_the_current_state_from_the_events_without_changing_versions() + { + var id = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var shoppingCartInitialized = new ShoppingCartInitialized(Guid.NewGuid(), clientId); + var productItemAddedToShoppingCart = new ProductItemAddedToShoppingCart(id, productId); + var shoppingCartConfirmed = new ShoppingCartConfirmed(id, DateTime.Now); + + // 1. Get all events and sort them in the order of appearance + var events = new object[] + { + shoppingCartInitialized, + productItemAddedToShoppingCart, + productItemAddedToShoppingCart, + shoppingCartConfirmed + }; + + // 2. Construct empty Invoice object + var shoppingCart = AggregateFactory.CreateAggregate(); + + // 3. Apply each event on the entity. + foreach (var @event in events) + { + shoppingCart.When(@event); + } + + shoppingCart.Id.Should().Be(shoppingCartInitialized.ShoppingCartId); + shoppingCart.Products.Count.Should().Be(2); + shoppingCart.ConfirmedAt.Should().Be(shoppingCartConfirmed.ConfirmedAt); + shoppingCart.ClientId.Should().Be(shoppingCartInitialized.ClientId); + + // Versions should not be touched on this action + shoppingCart.CurrentVersion.Should().Be(-1); + shoppingCart.OriginalVersion.Should().Be(-1); + } + + [Fact] + public void apply_event_should_update_state_and_add_event_to_uncommitted_events_with_increase_current_version() + { + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var shoppingCard = ShoppingCart.Create(clientId); + shoppingCard.AddItem(productId); + + shoppingCard.Products.Count.Should().Be(1); + shoppingCard.ClientId.Should().Be(clientId); + + shoppingCard.OriginalVersion.Should().Be(-1); + shoppingCard.CurrentVersion.Should().Be(1); + + var uncommittedEvents = shoppingCard.GetUncommittedDomainEvents(); + + uncommittedEvents.Count.Should().Be(2); + uncommittedEvents.Last().Should().BeOfType(); + ((Guid)uncommittedEvents.Last().AggregateId).Should().Be(shoppingCard.Id); + uncommittedEvents.Last().AggregateSequenceNumber.Should().Be(1); + } + + [Fact] + public void fold_should_apply_events_to_the_aggregate_state_and_increase_current_and_original_version() + { + var id = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var shoppingCartInitialized = new ShoppingCartInitialized(Guid.NewGuid(), clientId); + var productItemAddedToShoppingCart = new ProductItemAddedToShoppingCart(id, productId); + var shoppingCartConfirmed = new ShoppingCartConfirmed(id, DateTime.Now); + + // 1. Get all events and sort them in the order of appearance + var events = new object[] + { + shoppingCartInitialized, + productItemAddedToShoppingCart, + productItemAddedToShoppingCart, + shoppingCartConfirmed + }; + + // 2. Construct empty Invoice object + var shoppingCart = AggregateFactory.CreateAggregate(); + + // 3. Apply each event on the entity. + foreach (var @event in events) + { + shoppingCart.Fold(@event); + } + + shoppingCart.Id.Should().Be(shoppingCartInitialized.ShoppingCartId); + shoppingCart.Products.Count.Should().Be(2); + shoppingCart.ConfirmedAt.Should().Be(shoppingCartConfirmed.ConfirmedAt); + shoppingCart.ClientId.Should().Be(shoppingCartInitialized.ClientId); + + // Versions should be touched on this action + shoppingCart.CurrentVersion.Should().Be(3); + shoppingCart.OriginalVersion.Should().Be(3); + } + + [Fact] + public void flush_uncommitted_should_return_all_uncommitted_event_and_clear_this_list() + { + var clientId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + + var shoppingCard = ShoppingCart.Create(clientId); + shoppingCard.AddItem(productId); + + var uncommittedEvents = shoppingCard.GetUncommittedDomainEvents(); + uncommittedEvents.Count.Should().Be(2); + + uncommittedEvents.Last().Should().BeOfType(); + + shoppingCard.MarkUncommittedDomainEventAsCommitted(); + + shoppingCard.GetUncommittedDomainEvents().Count.Should().Be(0); + shoppingCard.CurrentVersion.Should().Be(shoppingCard.OriginalVersion); + } + + private record ShoppingCartInitialized(Guid ShoppingCartId, Guid ClientId) : DomainEvent; + + private record ProductItemAddedToShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ProductItemRemovedFromShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ShoppingCartConfirmed(Guid ShoppingCartId, DateTime ConfirmedAt) : DomainEvent; + + private enum ShoppingCartStatus + { + Pending = 1, + Confirmed = 2, + Cancelled = 4 + } + + private class ShoppingCart : EventSourcedAggregate + { + private List _products = new(); + + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IReadOnlyList Products => _products.AsReadOnly(); + public DateTime? ConfirmedAt { get; private set; } + + public static ShoppingCart Create(Guid clientId) + { + var shoppingCart = new ShoppingCart(); + + shoppingCart.ApplyEvent(new ShoppingCartInitialized(Guid.NewGuid(), clientId)); + + return shoppingCart; + } + + public void AddItem(Guid productId) + { + ApplyEvent(new ProductItemAddedToShoppingCart(Id, productId)); + } + + public void RemoveItem(Guid productId) + { + ApplyEvent(new ProductItemRemovedFromShoppingCart(Id, productId)); + } + + public void Confirm() + { + ApplyEvent(new ShoppingCartConfirmed(Id, DateTime.Now)); + } + + internal void Apply(ShoppingCartInitialized @event) + { + Id = @event.ShoppingCartId; + ClientId = @event.ClientId; + Status = ShoppingCartStatus.Pending; + _products = new List(); + } + + internal void Apply(ProductItemAddedToShoppingCart @event) + { + _products.Add(@event.ProductId); + } + + internal void Apply(ProductItemRemovedFromShoppingCart @event) + { + _products.Remove(@event.ProductId); + } + + internal void Apply(ShoppingCartConfirmed @event) + { + ConfirmedAt = @event.ConfirmedAt; + Status = ShoppingCartStatus.Confirmed; + } + } +} diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/StreamNameTests.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/StreamNameTests.cs new file mode 100644 index 00000000..24e9d931 --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/StreamNameTests.cs @@ -0,0 +1,20 @@ +using BuildingBlocks.Core.Domain.EventSourcing; +using BuildingBlocks.Core.Persistence.EventStore; +using FluentAssertions; + +namespace BuildingBlocks.Core.UnitTests; + +public class StreamNameTests +{ + [Fact] + public void should_return_correct_streamId_value_for_event_sourced_aggregate() + { + var id = Guid.NewGuid(); + var streamName = StreamName.For(id); + streamName.Should().NotBeNull(); + streamName.Value.Should().NotBeNullOrEmpty(); + streamName.Value.Should().Be($"{nameof(TestAggregate)}-{id.ToString()}"); + } +} + +public class TestAggregate : EventSourcedAggregate { } diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/TypeMapperTests.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/TypeMapperTests.cs new file mode 100644 index 00000000..06efce4d --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/TypeMapperTests.cs @@ -0,0 +1,27 @@ +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Types; +using FluentAssertions; + +namespace BuildingBlocks.Core.UnitTests; + +public class TypeMapperTests +{ + [Fact] + public void get_type_name_should_return_correct_name() + { + TypeMapper.GetTypeName().Should().Be(typeof(OrderCreated).FullName!.Replace(".", "_")); + TypeMapper.GetTypeName(typeof(OrderCreated)).Should().Be(typeof(OrderCreated).FullName!.Replace(".", "_")); + TypeMapper + .GetTypeNameByObject(new OrderCreated()) + .Should() + .Be(typeof(OrderCreated).FullName!.Replace(".", "_")); + } + + [Fact] + public void get_type_should_return_correct_type() + { + TypeMapper.GetType(nameof(OrderCreated)).Should().Be(); + } +} + +public record OrderCreated : DomainEvent; diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/Usings.cs b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/xunit.runner.json b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/xunit.runner.json new file mode 100644 index 00000000..9db029ba --- /dev/null +++ b/tests/BuildingBlocks/Core/BuildingBlocks.Core.UnitTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.csproj b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.csproj new file mode 100644 index 00000000..27f3fbc4 --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + + + + Always + + + + diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/DependencyInjectionsTests.cs b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/DependencyInjectionsTests.cs new file mode 100644 index 00000000..07234197 --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/DependencyInjectionsTests.cs @@ -0,0 +1,40 @@ +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Persistence.EventStoreDB.Extensions; +using EventStore.Client; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tests.Shared.Helpers; + +namespace BuildingBlocks.Persistence.EventStoreDB.IntegrationTests; + +public class DependencyInjectionsTests +{ + private readonly ServiceProvider _provider; + + public DependencyInjectionsTests() + { + var services = new ServiceCollection(); + var configuration = ConfigurationHelper.BuildConfiguration(); + + services.AddEventStoreDb(configuration); + services.AddInMemoryMessagePersistence(); + + _provider = services.BuildServiceProvider(); + } + + [Fact] + public void should_resolve_event_store_db() + { + var eventStoreClient = _provider.GetService(); + eventStoreClient.Should().NotBeNull(); + } + + [Fact] + public void should_resolve_event_store_db_options() + { + var options = _provider.GetService>(); + options.Should().NotBeNull(); + options!.Value.GrpcConnectionString.Should().Be("esdb://localhost:2113?tls=false"); + } +} diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/EventStoreDbEventStoreTests.cs b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/EventStoreDbEventStoreTests.cs new file mode 100644 index 00000000..ac52701b --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/EventStoreDbEventStoreTests.cs @@ -0,0 +1,263 @@ +using System.Collections.Immutable; +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Core.Domain; +using BuildingBlocks.Core.Domain.Events.Internal; +using BuildingBlocks.Core.Domain.EventSourcing; +using BuildingBlocks.Core.Persistence.EventStore; +using BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.Fixtures; +using FluentAssertions; + +namespace BuildingBlocks.Persistence.EventStoreDB.IntegrationTests; + +public class EventStoreDbEventStoreTests : IClassFixture +{ + private readonly IntegrationFixture _integrationFixture; + + public EventStoreDbEventStoreTests(IntegrationFixture integrationFixture) + { + _integrationFixture = integrationFixture; + } + + [Fact] + public async Task exist_stream_should_return_true_for_existing_stream() + { + var (streamId, _) = await AddInitItemToStore(); + + var exists = await _integrationFixture.EventStore.StreamExists(streamId); + + exists.Should().BeTrue(); + } + + [Fact] + public async Task append_multiple_and_get_stream_events_with_version_should_return_correct_events() + { + var shoppingCart = ShoppingCart.Create(Guid.NewGuid()); + shoppingCart.AddItem(Guid.NewGuid()); + shoppingCart.Confirm(); + + var streamId = StreamName.For(shoppingCart.Id); + + var uncommittedEvents = shoppingCart.GetUncommittedDomainEvents(); + shoppingCart.MarkUncommittedDomainEventAsCommitted(); + + var streamEvents = uncommittedEvents + .Select( + x => + x.ToStreamEvent( + new StreamEventMetadata(x.EventId.ToString(), (ulong)x.AggregateSequenceNumber, null, null) + ) + ) + .ToImmutableList(); + + var appendResult = await _integrationFixture.EventStore.AppendEventsAsync( + streamId, + streamEvents, + new ExpectedStreamVersion(shoppingCart.OriginalVersion) + ); + + var events = ( + await _integrationFixture.EventStore.GetStreamEventsAsync( + streamId, + StreamReadPosition.Start, + CancellationToken.None + ) + ).ToList(); + + appendResult.Should().NotBeNull(); + appendResult.NextExpectedVersion.Should().Be(2); + events.Should().NotBeNull(); + events.Count.Should().Be(3); + events.All(x => x.Metadata is not null).Should().BeTrue(); + events.Last().Should().BeOfType>(); + events.Last().Metadata!.StreamPosition.Should().Be(2); + } + + [Fact] + public async Task append_single_to_existing_stream_and_get_stream_events_with_version_should_return_correct_events() + { + var (streamId, _) = await AddInitItemToStore(); + + var defaultAggregateState = AggregateFactory.CreateAggregate(); + + var aggregate = await _integrationFixture.EventStore.AggregateStreamAsync( + streamId, + StreamReadPosition.Start, + defaultAggregateState, + defaultAggregateState.Fold, + CancellationToken.None + ); + + aggregate.Confirm(); + + var uncommittedEvents = aggregate.GetUncommittedDomainEvents(); + aggregate.MarkUncommittedDomainEventAsCommitted(); + + var streamEvent = uncommittedEvents + .Select( + x => + x.ToStreamEvent( + new StreamEventMetadata(x.EventId.ToString(), (ulong)x.AggregateSequenceNumber, null, null) + ) + ) + .ToImmutableList() + .First(); + + var appendResult = await _integrationFixture.EventStore.AppendEventAsync( + streamId, + streamEvent, + new ExpectedStreamVersion(aggregate.OriginalVersion) + ); + + var events = ( + await _integrationFixture.EventStore.GetStreamEventsAsync( + streamId, + StreamReadPosition.Start, + CancellationToken.None + ) + ).ToList(); + + appendResult.Should().NotBeNull(); + appendResult.NextExpectedVersion.Should().Be(2); + + events.Should().NotBeNull(); + events.Count.Should().Be(3); + events.All(x => x.Metadata is not null).Should().BeTrue(); + events.Last().Should().BeOfType>(); + events.Last().Metadata!.StreamPosition.Should().Be(2); + + aggregate.OriginalVersion.Should().Be(1); + aggregate.CurrentVersion.Should().Be(2); + } + + [Fact] + public async Task aggregate_stream_should_return_correct_aggregate() + { + var (streamId, shoppingCart) = await AddInitItemToStore(); + + var defaultAggregateState = AggregateFactory.CreateAggregate(); + + var aggregate = await _integrationFixture.EventStore.AggregateStreamAsync( + streamId, + StreamReadPosition.Start, + defaultAggregateState, + defaultAggregateState.Fold, + CancellationToken.None + ); + + aggregate.Id.Should().Be(shoppingCart.Id); + aggregate.Products.Count.Should().Be(shoppingCart.Products.Count); + + aggregate.OriginalVersion.Should().Be(1); + aggregate.CurrentVersion.Should().Be(1); + } + + private async Task<(StreamName streamName, ShoppingCart shoppingCart)> AddInitItemToStore() + { + var shoppingCart = ShoppingCart.Create(Guid.NewGuid()); + shoppingCart.AddItem(Guid.NewGuid()); + + var streamId = StreamName.For(shoppingCart.Id); + + var uncommittedEvents = shoppingCart.GetUncommittedDomainEvents(); + shoppingCart.MarkUncommittedDomainEventAsCommitted(); + + var streamEvents = uncommittedEvents + .Select( + x => + x.ToStreamEvent( + new StreamEventMetadata(x.EventId.ToString(), (ulong)x.AggregateSequenceNumber, null, null) + ) + ) + .ToImmutableList(); + + var appendResult = await _integrationFixture.EventStore.AppendEventsAsync( + streamId, + streamEvents, + new ExpectedStreamVersion(shoppingCart.OriginalVersion) + ); + + return (streamId, shoppingCart); + } + + private ShoppingCart InitShoppingCart() + { + var shoppingCart = ShoppingCart.Create(Guid.NewGuid()); + + Enumerable.Range(1, 100).ToList().ForEach(_ => shoppingCart.AddItem(Guid.NewGuid())); + + return shoppingCart; + } + + private record ShoppingCartInitialized(Guid ShoppingCartId, Guid ClientId) : DomainEvent; + + private record ProductItemAddedToShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ProductItemRemovedFromShoppingCart(Guid ShoppingCartId, Guid ProductId) : DomainEvent; + + private record ShoppingCartConfirmed(Guid ShoppingCartId, DateTime ConfirmedAt) : DomainEvent; + + private enum ShoppingCartStatus + { + Pending = 1, + Confirmed = 2, + Cancelled = 4 + } + + private class ShoppingCart : EventSourcedAggregate + { + private List _products = new(); + + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IReadOnlyList Products => _products.AsReadOnly(); + public DateTime? ConfirmedAt { get; private set; } + + public static ShoppingCart Create(Guid clientId) + { + var shoppingCart = new ShoppingCart(); + + shoppingCart.ApplyEvent(new ShoppingCartInitialized(Guid.NewGuid(), clientId)); + + return shoppingCart; + } + + public void AddItem(Guid productId) + { + ApplyEvent(new ProductItemAddedToShoppingCart(Id, productId)); + } + + public void RemoveItem(Guid productId) + { + ApplyEvent(new ProductItemRemovedFromShoppingCart(Id, productId)); + } + + public void Confirm() + { + ApplyEvent(new ShoppingCartConfirmed(Id, DateTime.Now)); + } + + internal void Apply(ShoppingCartInitialized @event) + { + Id = @event.ShoppingCartId; + ClientId = @event.ClientId; + Status = ShoppingCartStatus.Pending; + _products = new List(); + } + + internal void Apply(ProductItemAddedToShoppingCart @event) + { + _products.Add(@event.ProductId); + } + + internal void Apply(ProductItemRemovedFromShoppingCart @event) + { + _products.Remove(@event.ProductId); + } + + internal void Apply(ShoppingCartConfirmed @event) + { + ConfirmedAt = @event.ConfirmedAt; + Status = ShoppingCartStatus.Confirmed; + } + } +} diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/Fixtures/IntegrationFixture.cs b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/Fixtures/IntegrationFixture.cs new file mode 100644 index 00000000..2949ee2a --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/Fixtures/IntegrationFixture.cs @@ -0,0 +1,43 @@ +using BuildingBlocks.Abstractions.Persistence.EventStore; +using BuildingBlocks.Core.Registrations; +using BuildingBlocks.Persistence.EventStoreDB.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tests.Shared.Helpers; + +namespace BuildingBlocks.Persistence.EventStoreDB.IntegrationTests.Fixtures; + +public class IntegrationFixture : IAsyncLifetime +{ + private readonly ServiceProvider _provider; + + public IntegrationFixture() + { + var services = new ServiceCollection(); + var configuration = ConfigurationHelper.BuildConfiguration(); + + services.AddCore(); + services.AddLogging(builder => + { + builder.AddXUnit(); + }); + services.AddCqrs(); + services.AddEventStoreDb(configuration); + services.AddHttpContextAccessor(); + + _provider = services.BuildServiceProvider(); + } + + public IEventStore EventStore => _provider.GetRequiredService(); + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + GC.SuppressFinalize(this); + return Task.CompletedTask; + } +} diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/appsettings.json b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/appsettings.json new file mode 100644 index 00000000..395f0226 --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/appsettings.json @@ -0,0 +1,11 @@ +{ + "EventStoreDbOptions": { + "ConnectionString": "esdb://localhost:2113?tls=false", + "UseInternalCheckpointing": false, + "SubscriptionOptions": { + "SubscriptionId": "default", + "ResolveLinkTos": false, + "IgnoreDeserializationErrors": true + } + } +} \ No newline at end of file diff --git a/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/xunit.runner.json b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..9db029ba --- /dev/null +++ b/tests/BuildingBlocks/EventStoreDB/BuildingBlocks.Persistence.EventStoreDB.IntegrationTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/BuildingBlocks.Persistence.Marten.IntegrationTests.csproj b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/BuildingBlocks.Persistence.Marten.IntegrationTests.csproj new file mode 100644 index 00000000..cde056f5 --- /dev/null +++ b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/BuildingBlocks.Persistence.Marten.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + + + + Always + + + + diff --git a/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/appsettings.json b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/appsettings.json new file mode 100644 index 00000000..de53b581 --- /dev/null +++ b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/appsettings.json @@ -0,0 +1,11 @@ +{ + "MartenOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=marten-test;User Id=postgres;Password=postgres;Include Error Detail=true", + "UseMetadata": true + }, + "OutboxOptions": { + "ConnectionString": "Server=localhost;Port=5432;Database=marten-test;User Id=postgres;Password=postgres;Include Error Detail=true", + "Enabled": true, + "UseBackgroundDispatcher": true + } +} \ No newline at end of file diff --git a/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/xunit.runner.json b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..9db029ba --- /dev/null +++ b/tests/BuildingBlocks/Marten/BuildingBlocks.Persistence.Marten.IntegrationTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/BuildingBlocks/MassTransit/BuildingBlocks.Integration.MassTransit.IntegrationTests/BuildingBlocks.Integration.MassTransit.IntegrationTests.csproj b/tests/BuildingBlocks/MassTransit/BuildingBlocks.Integration.MassTransit.IntegrationTests/BuildingBlocks.Integration.MassTransit.IntegrationTests.csproj index adfc803f..f31fee71 100644 --- a/tests/BuildingBlocks/MassTransit/BuildingBlocks.Integration.MassTransit.IntegrationTests/BuildingBlocks.Integration.MassTransit.IntegrationTests.csproj +++ b/tests/BuildingBlocks/MassTransit/BuildingBlocks.Integration.MassTransit.IntegrationTests/BuildingBlocks.Integration.MassTransit.IntegrationTests.csproj @@ -9,12 +9,6 @@ - - - - - - diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index ae33bca2..0d148363 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,7 +2,7 @@ - net7.0 + net8.0 latest enable enable diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index ca0df9bc..a2af3e12 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -5,9 +5,9 @@ true - 1.2.4 - 1.2.4 - 1.2.4 + 1.2.4-dev.4 + 1.2.4-dev.4 + 1.2.4-dev.4 false @@ -16,37 +16,42 @@ - - - - + + + + - - - - + + + + + - - - - + + + + + - - - - - + + + + + + + + + - - + - - - + + + \ No newline at end of file diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/DependencyTests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/DependencyTests.cs new file mode 100644 index 00000000..7f53510d --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/DependencyTests.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Core.Extensions.ServiceCollection; +using FoodDelivery.Services.Catalogs.Api; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Catalogs.DependencyTests; + +public class DependencyTests +{ + [Fact] + public void validate_service_dependencies() + { + var factory = new WebApplicationFactory().WithWebHostBuilder(webHostBuilder => + { + webHostBuilder.UseEnvironment("test"); + + webHostBuilder.ConfigureTestServices(services => + { + services.TryAddTransient(_ => services); + }); + }); + + using var scope = factory.Services.CreateScope(); + var sp = scope.ServiceProvider; + var services = sp.GetRequiredService(); + sp.ValidateDependencies(services, typeof(CatalogsApiMetadata).Assembly, typeof(CatalogsMetadata).Assembly); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/FoodDelivery.Services.Catalogs.DependencyTests.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/FoodDelivery.Services.Catalogs.DependencyTests.csproj new file mode 100644 index 00000000..c7f39983 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/FoodDelivery.Services.Catalogs.DependencyTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/Usings.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.DependencyTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/FoodDelivery.Services.Catalogs.EndToEndTests.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/FoodDelivery.Services.Catalogs.EndToEndTests.csproj new file mode 100644 index 00000000..acf0b9f3 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/FoodDelivery.Services.Catalogs.EndToEndTests.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/UnitTest1.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/UnitTest1.cs new file mode 100644 index 00000000..384b26af --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/UnitTest1.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Catalogs.EndToEndTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() { } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/XunitMetadata.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/xunit.runner.json b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.EndToEndTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/FoodDelivery.Services.Catalogs.IntegrationTests.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/FoodDelivery.Services.Catalogs.IntegrationTests.csproj new file mode 100644 index 00000000..0a1df5a5 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/FoodDelivery.Services.Catalogs.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/UnitTest1.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/UnitTest1.cs new file mode 100644 index 00000000..12c326f8 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/UnitTest1.cs @@ -0,0 +1,61 @@ +using Bogus; +using BuildingBlocks.Core.Extensions; +using FoodDelivery.Services.Catalogs.Brands; +using FoodDelivery.Services.Catalogs.Brands.Contracts; +using FoodDelivery.Services.Catalogs.Brands.Data; +using FoodDelivery.Services.Catalogs.Brands.ValueObjects; +using FoodDelivery.Services.Catalogs.Categories; +using FoodDelivery.Services.Catalogs.Categories.Data; +using FoodDelivery.Services.Catalogs.Products.Models; +using FoodDelivery.Services.Catalogs.Products.ValueObjects; +using FoodDelivery.Services.Catalogs.Suppliers; +using FoodDelivery.Services.Catalogs.Suppliers.Contracts; +using FoodDelivery.Services.Catalogs.Suppliers.Data; +using NSubstitute; + +namespace FoodDelivery.Services.Catalogs.IntegrationTests; + +public class UnitTest1 +{ + [Fact] + [Trait("Category", "Integration")] + public void Test1() + { + long id = 1; + var category = new CategoryFaker().Generate(); + var supplier = new SupplierFaker().Generate(); + var brand = new BrandFaker().Generate(); + + var supplierChecker = Substitute.For(); + supplierChecker.SupplierExists(Arg.Any()).Returns(true); + + var brandChecker = Substitute.For(); + brandChecker.BrandExists(Arg.Any()).Returns(true); + + // Call for objects that have complex initialization + var productFaker = new Faker().CustomInstantiator( + faker => + Product.Create( + ProductId.Of(id++), + Name.Of(faker.Commerce.ProductName()), + ProductInformation.Of(faker.Commerce.ProductName(), faker.Commerce.ProductDescription()), + Stock.Of(faker.Random.Int(10, 20), 5, 20), + ProductStatus.Available, + faker.Random.Enum(), + Dimensions.Of(faker.Random.Int(10, 50), faker.Random.Int(10, 50), faker.Random.Int(10, 50)), + Size.Of(faker.PickRandom("M", "S", "L")), + faker.Random.Enum(), + faker.Commerce.ProductDescription(), + Price.Of(faker.PickRandom(100, 200, 500)), + category.Id, + SupplierId.Of(faker.Random.Long(1, 5)), + BrandId.Of(faker.Random.Long(1, 5)), + _ => Task.FromResult(true)!, + supplierChecker, + brandChecker + ) + ); + + var s = productFaker.Generate(5); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/XunitMetadata.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/xunit.runner.json b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.IntegrationTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/FoodDelivery.Services.Catalogs.LoadTests.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/FoodDelivery.Services.Catalogs.LoadTests.csproj new file mode 100644 index 00000000..f9d51cb8 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/FoodDelivery.Services.Catalogs.LoadTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + PreserveNewest + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/UnitTest1.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/UnitTest1.cs new file mode 100644 index 00000000..f20cfbc3 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/UnitTest1.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Catalogs.LoadTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() { } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/XunitMetadata.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/xunit.runner.json b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.LoadTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Banners/Tests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Banners/Tests.cs new file mode 100644 index 00000000..595ae1c3 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Banners/Tests.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Catalogs.Brands.Data; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Catalogs.TestShared.Fakes.Banners; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void BrandFaker() + { + var banners = new BrandFaker().Generate(5); + banners.All(x => x.Id > 0).Should().BeTrue(); + banners.All(x => string.IsNullOrWhiteSpace(x.Name)).Should().BeFalse(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Categories/Tests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Categories/Tests.cs new file mode 100644 index 00000000..532300a0 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Categories/Tests.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Catalogs.Categories.Data; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Catalogs.TestShared.Fakes.Categories; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void CategoryFaker() + { + var categories = new CategoryFaker().Generate(5); + categories.All(x => x.Id > 0).Should().BeTrue(); + categories.All(x => string.IsNullOrWhiteSpace(x.Name)).Should().BeFalse(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Products/Tests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Products/Tests.cs new file mode 100644 index 00000000..cbe9b9dd --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Products/Tests.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Catalogs.Products.Data; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Catalogs.TestShared.Fakes.Products; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void ProductFaker() + { + var products = new ProductFaker().Generate(5); + products.All(x => x.Id > 0).Should().BeTrue(); + products.All(x => string.IsNullOrWhiteSpace(x.Name)).Should().BeFalse(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Suppliers/Tests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Suppliers/Tests.cs new file mode 100644 index 00000000..3d6d0ac2 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/Fakes/Suppliers/Tests.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Catalogs.Suppliers.Data; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Catalogs.TestShared.Fakes.Suppliers; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void SupplierFaker() + { + var suppliers = new SupplierFaker().Generate(5); + suppliers.All(x => x.Id > 0).Should().BeTrue(); + suppliers.All(x => string.IsNullOrWhiteSpace(x.Name)).Should().BeFalse(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/FoodDelivery.Services.Catalogs.TestShared.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/FoodDelivery.Services.Catalogs.TestShared.csproj new file mode 100644 index 00000000..9754cafb --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.TestShared/FoodDelivery.Services.Catalogs.TestShared.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/CatalogsServiceUnitTestBase.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/CatalogsServiceUnitTestBase.cs new file mode 100644 index 00000000..e0a72f77 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/CatalogsServiceUnitTestBase.cs @@ -0,0 +1,30 @@ +using AutoMapper; +using FoodDelivery.Services.Catalogs.Shared.Data; +using FoodDelivery.Services.Customers.UnitTests; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Catalogs.UnitTests.Common; + +[CollectionDefinition(nameof(QueryTestCollection))] +public class QueryTestCollection : ICollectionFixture { } + +//https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection +[Collection(UnitTestCollection.Name)] +[CategoryTrait(TestCategory.Unit)] +public class CatalogsServiceUnitTestBase : IAsyncDisposable +{ + public CatalogsServiceUnitTestBase() + { + Mapper = MapperFactory.Create(); + CatalogDbContext = DbContextFactory.Create(); + } + + public IMapper Mapper { get; } + public CatalogDbContext CatalogDbContext { get; } + + public async ValueTask DisposeAsync() + { + await DbContextFactory.Destroy(CatalogDbContext); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/DbContextFactory.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/DbContextFactory.cs new file mode 100644 index 00000000..e220078d --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/DbContextFactory.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Persistence.EfCore.Interceptors; +using FoodDelivery.Services.Catalogs.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FoodDelivery.Services.Catalogs.UnitTests.Common; + +public static class DbContextFactory +{ + public static CatalogDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + // ref: https://andrewlock.net/series/using-strongly-typed-entity-ids-to-avoid-primitive-obsession/ + .ReplaceService>() + .AddInterceptors(new AuditInterceptor(), new SoftDeleteInterceptor(), new ConcurrencyInterceptor()) + .Options; + + var context = new CatalogDbContext(options); + context.Database.EnsureCreated(); + + return context; + } + + public static async Task Destroy(CatalogDbContext context) + { + await context.Database.EnsureDeletedAsync(); + await context.DisposeAsync(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MapperFactory.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MapperFactory.cs new file mode 100644 index 00000000..e0cac9cc --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MapperFactory.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using FoodDelivery.Services.Catalogs.Products; + +namespace FoodDelivery.Services.Catalogs.UnitTests.Common; + +public static class MapperFactory +{ + public static IMapper Create() + { + var configurationProvider = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + }); + + return configurationProvider.CreateMapper(); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MappingFixture.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MappingFixture.cs new file mode 100644 index 00000000..7d5aa9c0 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Common/MappingFixture.cs @@ -0,0 +1,13 @@ +using AutoMapper; + +namespace FoodDelivery.Services.Catalogs.UnitTests.Common; + +public class MappingFixture +{ + public MappingFixture() + { + Mapper = MapperFactory.Create(); + } + + public IMapper Mapper { get; } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/FoodDelivery.Services.Catalogs.UnitTests.csproj b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/FoodDelivery.Services.Catalogs.UnitTests.csproj new file mode 100644 index 00000000..f9d51cb8 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/FoodDelivery.Services.Catalogs.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + PreserveNewest + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Products/ProductsMappingTests.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Products/ProductsMappingTests.cs new file mode 100644 index 00000000..25c040df --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/Products/ProductsMappingTests.cs @@ -0,0 +1,35 @@ +using AutoBogus; +using AutoMapper; +using FoodDelivery.Services.Catalogs.Products.Features.CreatingProduct.v1; +using FoodDelivery.Services.Catalogs.UnitTests.Common; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Products; + +public class ProductsMappingTests : IClassFixture +{ + private readonly IMapper _mapper; + + public ProductsMappingTests(MappingFixture fixture) + { + _mapper = fixture.Mapper; + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_configuration() + { + _mapper.ConfigurationProvider.AssertConfigurationIsValid(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_create_product_request_create_product() + { + var createProductRequest = new AutoFaker().Generate(); + var res = _mapper.Map(createProductRequest); + res.Name.Should().Be(createProductRequest.Name); + res.Price.Should().Be(createProductRequest.Price); + } +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/UnitTestCollection.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/UnitTestCollection.cs new file mode 100644 index 00000000..b06b0a6c --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/UnitTestCollection.cs @@ -0,0 +1,9 @@ +namespace FoodDelivery.Services.Customers.UnitTests; + +// https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions +[CollectionDefinition(Name)] +public class UnitTestCollection +{ + public const string Name = "UnitTest Test"; +} diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/XunitMetadata.cs b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/xunit.runner.json b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Catalogs/FoodDelivery.Services.Catalogs.UnitTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/DependencyTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/DependencyTests.cs new file mode 100644 index 00000000..0534e9c3 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/DependencyTests.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Core.Extensions.ServiceCollection; +using FoodDelivery.Services.Customers.Api; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FoodDelivery.Services.Customers.DependencyTests; + +public class DependencyTests +{ + [Fact] + public void validate_service_dependencies() + { + var factory = new WebApplicationFactory().WithWebHostBuilder(webHostBuilder => + { + webHostBuilder.UseEnvironment("test"); + + webHostBuilder.ConfigureTestServices(services => + { + services.TryAddTransient(_ => services); + }); + }); + + using var scope = factory.Services.CreateScope(); + var sp = scope.ServiceProvider; + var services = sp.GetRequiredService(); + sp.ValidateDependencies(services, typeof(CustomersApiMetadata).Assembly, typeof(CustomersMetadata).Assembly); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/FoodDelivery.Services.Customers.DependencyTests.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/FoodDelivery.Services.Customers.DependencyTests.csproj new file mode 100644 index 00000000..6a0e369e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/FoodDelivery.Services.Customers.DependencyTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/Usings.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/Usings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.DependencyTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Constants.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Constants.cs new file mode 100644 index 00000000..5140158e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Constants.cs @@ -0,0 +1,40 @@ +namespace FoodDelivery.Services.Customers.EndToEndTests; + +public class Constants +{ + public static class Routes + { + private const string RootBaseAddress = "api/v1/customers"; + public const string Health = $"{RootBaseAddress}/healthz"; + + public static class Customers + { + private const string CustomersBaseAddress = $"{RootBaseAddress}"; + + public static string GetByPage => $"{CustomersBaseAddress}/"; + + public static string GetById(Guid id) => $"{CustomersBaseAddress}/{id}"; + + public static string Delete(long id) => $"{CustomersBaseAddress}/{id}"; + + public static string Put(long id) => $"{CustomersBaseAddress}/{id}"; + + public static string Create => $"{CustomersBaseAddress}/"; + } + + public static class RestockSubscription + { + private const string RestockSubscriptionBaseAddress = $"{RootBaseAddress}/restock-subscriptions"; + + public static string GetByPage => $"{RestockSubscriptionBaseAddress}/"; + + public static string GetById(Guid id) => $"{RestockSubscriptionBaseAddress}/{id}"; + + public static string Delete(long id) => $"{RestockSubscriptionBaseAddress}/{id}"; + + public static string Put(long id) => $"{RestockSubscriptionBaseAddress}/{id}"; + + public static string Create => $"{RestockSubscriptionBaseAddress}/"; + } + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs new file mode 100644 index 00000000..6143748c --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs @@ -0,0 +1,69 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Tests.Shared.Fixtures; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.EndToEndTests; + +[Collection(EndToEndTestCollection.Name)] +public class CustomerServiceEndToEndTestBase + : EndToEndTestTestBase +{ + // We don't need to inject `CustomersServiceMockServersFixture` class fixture in the constructor because it initialized by `collection fixture` and its static properties are accessible in the codes + public CustomerServiceEndToEndTestBase( + SharedFixtureWithEfCoreAndMongo< + Api.CustomersApiMetadata, + CustomersDbContext, + CustomersReadDbContext + > sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + // https://pcholko.com/posts/2021-04-05/wiremock-integration-test/ + // note1: for E2E test we use real identity service in on a TestContainer docker of this service, coordination with an external system is necessary in E2E + + // note2: add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration + // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ + SharedFixture.Configuration["IdentityApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture + .IdentityServiceMock + .Url; + SharedFixture.Configuration["CatalogsApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture + .CatalogsServiceMock + .Url; + + // var catalogApiOptions = Scope.ServiceProvider.GetRequiredService>(); + // var identityApiOptions = Scope.ServiceProvider.GetRequiredService>(); + // + // identityApiOptions.Value.BaseApiAddress = MockServersFixture.IdentityServiceMock.Url!; + // catalogApiOptions.Value.BaseApiAddress = MockServersFixture.CatalogsServiceMock.Url!; + } + + protected override void RegisterTestAppConfigurations( + IConfigurationBuilder builder, + IConfiguration configuration, + IHostEnvironment environment + ) + { + base.RegisterTestAppConfigurations(builder, configuration, environment); + } + + protected override void RegisterTestConfigureServices(IServiceCollection services) + { + //// here we use same data seeder of service but if we need different data seeder for test for can replace it + // services.ReplaceScoped(); + } + + public override Task DisposeAsync() + { + // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests + CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); + CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); + + return base.DisposeAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs new file mode 100644 index 00000000..473975dd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs @@ -0,0 +1,124 @@ +using BuildingBlocks.Validation; +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Requests; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Tests.Shared.Extensions; + +namespace FoodDelivery.Services.Customers.EndToEndTests.Customers.Features.CreatingCustomer.v1; + +public class CreateCustomerTests : CustomerServiceEndToEndTestBase +{ + public CreateCustomerTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + AssertionOptions.AssertEquivalencyUsing(options => options.ExcludingMissingMembers()); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task can_returns_created_status_code_using_valid_dto_and_auth_credentials() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var fakeCreateCustomerRequest = new FakeCreateCustomerRequest(fakeIdentityUser!.Email).Generate(); + var route = Constants.Routes.Customers.Create; + + // Act + var response = await SharedFixture.AdminHttpClient.PostAsJsonAsync(route, fakeCreateCustomerRequest); + + // Assert + response.Should().Be201Created(); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task can_returns_valid_response_using_valid_dto_and_auth_credentials() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var fakeCreateCustomerRequest = new FakeCreateCustomerRequest(fakeIdentityUser!.Email).Generate(); + var route = Constants.Routes.Customers.Create; + + // Act + var response = await SharedFixture.AdminHttpClient.PostAsJsonAsync(route, fakeCreateCustomerRequest); + + //response.Should().Satisfy(x => x.CustomerId.Should().BeGreaterThan(0)); + + // Assert + response + .Should() + .HasResponse(responseAction: customerResponse => + { + customerResponse!.CustomerId.Should().BeGreaterThan(0); + }); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task must_returns_conflict_status_code_when_customer_already_exists() + { + // Arrange + var fakeCustomer = new FakeCustomer().Generate(); + await SharedFixture.InsertEfDbContextAsync(fakeCustomer); + var createCustomerRequest = new CreateCustomerRequest(fakeCustomer.Email); + var route = Constants.Routes.Customers.Create; + + // Act + var response = await SharedFixture.AdminHttpClient.PostAsJsonAsync(route, createCustomerRequest); + + // Assert + response + .Should() + .HasProblemDetail( + new + { + Detail = $"Customer with email '{fakeCustomer.Email.Value}' already exists.", + Title = nameof(CustomerAlreadyExistsException), + Type = "https://somedomain/application-error", + } + ) + .And.Be409Conflict(); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task must_returns_bad_request_status_code_when_email_is_invalid() + { + // Arrange + var invalidEmail = "invalid_email"; + var createCustomerRequest = new CreateCustomerRequest(invalidEmail); + var route = Constants.Routes.Customers.Create; + + // Act + var response = await SharedFixture.AdminHttpClient.PostAsJsonAsync(route, createCustomerRequest); + + // Assert + response + .Should() + .ContainsProblemDetail( + new ProblemDetails + { + Detail = "Email address is invalid.", + Title = nameof(ValidationException), + Type = "https://somedomain/input-validation-rules-error" + } + ) + .And.Be400BadRequest(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs new file mode 100644 index 00000000..80a8b8ef --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs @@ -0,0 +1,162 @@ +using BuildingBlocks.Validation; +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomerById.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Tests.Shared.Extensions; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; +using Guid = System.Guid; + +namespace FoodDelivery.Services.Customers.EndToEndTests.Customers.Features.GettingCustomerById.v1; + +public class GetCustomerByIdTests : CustomerServiceEndToEndTestBase +{ + public GetCustomerByIdTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task can_returns_ok_status_code_using_valid_id_and_auth_credentials() + { + // Arrange + var fakeCustomer = new FakeCustomerReadModel().Generate(); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomer); + + var route = Constants.Routes.Customers.GetById(fakeCustomer.Id); + + // Act + var response = await SharedFixture.NormalUserHttpClient.GetAsync(route); + + // Assert + response.Should().Be200Ok(); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task can_returns_valid_response_using_valid_id_and_auth_credentials() + { + // Arrange + var fakeCustomer = new FakeCustomerReadModel().Generate(); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomer); + + var route = Constants.Routes.Customers.GetById(fakeCustomer.Id); + + // Act + var response = await SharedFixture.NormalUserHttpClient.GetAsync(route); + + // Assert + response.Should().Satisfy(x => x.Customer.Should().BeEquivalentTo(fakeCustomer)); + + // // OR + // response + // .Should() + // .BeAs( + // new + // { + // Customer = new + // { + // Id = fakeCustomer.Id, + // CustomerId = fakeCustomer.CustomerId, + // IdentityId = fakeCustomer.IdentityId + // } + // } + // ); + + // // OR + // response + // .Should() + // .Satisfy( + // givenModelStructure: new + // { + // Customer = new + // { + // Id = default(Guid), + // CustomerId = default(long), + // IdentityId = default(Guid) + // } + // }, + // assertion: model => + // { + // model.Customer.CustomerId.Should().Be(fakeCustomer.CustomerId); + // model.Customer.Id.Should().Be(fakeCustomer.Id); + // model.Customer.IdentityId.Should().Be(fakeCustomer.IdentityId); + // } + // ); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task must_returns_not_found_status_code_when_customer_not_exists() + { + // Arrange + var notExistsId = Guid.NewGuid(); + var route = Constants.Routes.Customers.GetById(notExistsId); + + // Act + var response = await SharedFixture.AdminHttpClient.GetAsync(route); + + // Assert + response + .Should() + .Satisfy(pr => + { + pr.Detail.Should().Be($"Customer with id '{notExistsId}' not found."); + pr.Title.Should().Be(nameof(CustomerNotFoundException)); + pr.Type.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + }) + .And.Be404NotFound(); + + // // OR + // response + // .Should() + // .HaveError("title", nameof(CustomerNotFoundException)) + // .And.HaveError("type", "https://tools.ietf.org/html/rfc7231#section-6.5.4") + // .And.HaveErrorMessage($"Customer with id '{notExistsId}' not found.") + // .And.Be404NotFound(); + } + + [Fact] + [CategoryTrait(TestCategory.EndToEnd)] + public async Task must_returns_bad_request_status_code_with_invalid() + { + // Arrange + var invalidId = Guid.Empty; + var route = Constants.Routes.Customers.GetById(invalidId); + + // Act + var response = await SharedFixture.AdminHttpClient.GetAsync(route); + + // Assert + + response + .Should() + .Satisfy(pr => + { + pr.Detail.Should().Be("'Id' must not be empty."); + pr.Title.Should().Be(nameof(ValidationException)); + pr.Type.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1"); + }) + .And.Be400BadRequest(); + + // // OR + // response + // .Should() + // .ContainsProblemDetail( + // new ProblemDetails + // { + // Detail = "'Id' must not be empty.", + // Title = nameof(ValidationException), + // Type = "https://somedomain/input-validation-rules-error", + // } + // ) + // .And.Be400BadRequest(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs new file mode 100644 index 00000000..1d1634e9 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using Tests.Shared.Fixtures; + +namespace FoodDelivery.Services.Customers.EndToEndTests; + +// https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions +[CollectionDefinition(Name)] +public class EndToEndTestCollection + : ICollectionFixture< + SharedFixtureWithEfCoreAndMongo + >, + ICollectionFixture +{ + public const string Name = "End-To-End Test"; +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/FoodDelivery.Services.Customers.EndToEndTests.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/FoodDelivery.Services.Customers.EndToEndTests.csproj new file mode 100644 index 00000000..bc14a286 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/FoodDelivery.Services.Customers.EndToEndTests.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/XunitMetadata.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/xunit.runner.json b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/.vscode/settings.json b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/.vscode/settings.json new file mode 100644 index 00000000..d8da76c7 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "omnisharp.testRunSettings": ".runsettings" +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs new file mode 100644 index 00000000..d27c0df8 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs @@ -0,0 +1,71 @@ +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Tests.Shared.Fixtures; +using Tests.Shared.TestBase; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests; + +//https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection +[Collection(IntegrationTestCollection.Name)] +public class CustomerServiceIntegrationTestBase + : IntegrationTestBase +{ + // We don't need to inject `CustomersServiceMockServersFixture` class fixture in the constructor because it initialized by `collection fixture` and its static properties are accessible in the codes + public CustomerServiceIntegrationTestBase( + SharedFixtureWithEfCoreAndMongo< + Api.CustomersApiMetadata, + CustomersDbContext, + CustomersReadDbContext + > sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + // https://pcholko.com/posts/2021-04-05/wiremock-integration-test/ + // note1: for E2E test we use real identity service in on a TestContainer docker of this service, coordination with an external system is necessary in E2E + + // note2: add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration + // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ + SharedFixture.Configuration["IdentityApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture + .IdentityServiceMock + .Url; + SharedFixture.Configuration["CatalogsApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture + .CatalogsServiceMock + .Url; + + // var catalogApiOptions = Scope.ServiceProvider.GetRequiredService>(); + // var identityApiOptions = Scope.ServiceProvider.GetRequiredService>(); + // + // identityApiOptions.Value.BaseApiAddress = MockServersFixture.IdentityServiceMock.Url!; + // catalogApiOptions.Value.BaseApiAddress = MockServersFixture.CatalogsServiceMock.Url!; + } + + protected override void RegisterTestAppConfigurations( + IConfigurationBuilder builder, + IConfiguration configuration, + IHostEnvironment environment + ) + { + base.RegisterTestAppConfigurations(builder, configuration, environment); + } + + protected override void RegisterTestConfigureServices(IServiceCollection services) + { + //// here we use same data seeder of service but if we need different data seeder for test for can replace it + // services.ReplaceScoped(); + } + + public override Task DisposeAsync() + { + // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests + CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); + CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); + + return base.DisposeAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs new file mode 100644 index 00000000..f417d9c9 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs @@ -0,0 +1,172 @@ +using System.Net; +using BuildingBlocks.Core.Exception.Types; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; +using MongoDB.Driver; + +namespace FoodDelivery.Services.Customers.IntegrationTests.Customers.Features.CreatingCustomer.v1; + +public class CreateCustomerTests : CustomerServiceIntegrationTestBase +{ + public CreateCustomerTests( + SharedFixtureWithEfCoreAndMongo< + Api.CustomersApiMetadata, + CustomersDbContext, + CustomersReadDbContext + > sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_create_new_customer_with_valid_input_in_postgres_db() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + var createdCustomerResponse = await SharedFixture.SendAsync(command); + + // Assert + createdCustomerResponse.CustomerId.Should().BeGreaterThan(0); + createdCustomerResponse.CustomerId.Should().Be(command.Id); + createdCustomerResponse.IdentityUserId.Should().Be(fakeIdentityUser.Id); + + var createdCustomer = await SharedFixture.ExecuteEfDbContextAsync( + async db => await db.Customers.SingleOrDefaultAsync(x => x.Id == createdCustomerResponse.CustomerId) + ); + + createdCustomer.Should().NotBeNull(); + createdCustomer!.IdentityId.Should().Be(fakeIdentityUser.Id); + createdCustomer.Email.Value.Should().Be(fakeIdentityUser.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task must_throw_exception_when_identity_user_with_customer_email_not_exists() + { + // Arrange + var command = new CreateCustomer("test@example.com"); + + // Act + Func act = async () => await SharedFixture.SendAsync(command); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should() + .ThrowAsync() + .Where(x => x.StatusCode == StatusCodes.Status404NotFound && !string.IsNullOrWhiteSpace(x.Message)); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task must_throw_exception_when_customer_with_email_already_exists() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + await SharedFixture.SendAsync(command); + + Func act = async () => await SharedFixture.SendAsync(new CreateCustomer(fakeIdentityUser.Email)); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_save_mongo_customer_read_model_in_internal_persistence_message() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + await SharedFixture.SendAsync(command); + + // Assert + await SharedFixture.ShouldProcessedPersistInternalCommand(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_create_new_mongo_customer_read_model_in_the_mongodb() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + await SharedFixture.SendAsync(command); + + // Assert + await SharedFixture.WaitUntilConditionMet(async () => + { + var existsCustomer = await SharedFixture.ExecuteMongoDbContextAsync(async ctx => + { + var res = ctx.Customers.AsQueryable().Any(x => x.Email == command.Email); + + return res; + }); + + return existsCustomer; + }); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_publish_customer_created_integration_event_to_the_broker() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + var _ = await SharedFixture.SendAsync(command); + + // Assert + await SharedFixture.WaitForPublishing(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_save_customer_created_integration_event_in_the_outbox() + { + // Arrange + var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + .SetupGetUserByEmail() + .Response.UserIdentity; + var command = new CreateCustomer(fakeIdentityUser!.Email); + + // Act + var _ = await SharedFixture.SendAsync(command); + + // Assert + await SharedFixture.ShouldProcessedOutboxPersistMessage(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdTests.cs new file mode 100644 index 00000000..86ad2a2e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerByCustomerId/v1/GetCustomerByCustomerIdTests.cs @@ -0,0 +1,51 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomerByCustomerId.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FluentAssertions; +using Humanizer; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests.Customers.Features.GettingCustomerByCustomerId.v1; + +public class GetCustomerByCustomerIdTests : CustomerServiceIntegrationTestBase +{ + public GetCustomerByCustomerIdTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task can_returns_valid_read_customer_model() + { + // Arrange + Customer fakeCustomer = new FakeCustomerReadModel().Generate(); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomer); + + // Act + var query = new GetCustomerByCustomerId(fakeCustomer.CustomerId); + var customer = (await SharedFixture.SendAsync(query)).Customer; + + // Assert + customer.Should().BeEquivalentTo(fakeCustomer, options => options.ExcludingMissingMembers()); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task must_throw_not_found_exception_when_item_does_not_exists_in_mongodb() + { + // Act + var query = new GetCustomerByCustomerId(100); + Func act = async () => _ = await SharedFixture.SendAsync(query); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs new file mode 100644 index 00000000..3e6df0bd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs @@ -0,0 +1,50 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomerById.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FluentAssertions; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests.Customers.Features.GettingCustomerById.v1; + +public class GetCustomerByIdTests : CustomerServiceIntegrationTestBase +{ + public GetCustomerByIdTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task can_returns_valid_read_customer_model() + { + // Arrange + Customer fakeCustomer = new FakeCustomerReadModel().Generate(); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomer); + + // Act + var query = new GetCustomerById(fakeCustomer.Id); + var customer = (await SharedFixture.SendAsync(query)).Customer; + + // Assert + customer.Should().BeEquivalentTo(fakeCustomer, options => options.ExcludingMissingMembers()); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task must_throw_not_found_exception_when_item_does_not_exists_in_mongodb() + { + // Act + var query = new GetCustomerById(Guid.NewGuid()); + Func act = async () => _ = await SharedFixture.SendAsync(query); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomers/v1/GetCustomersTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomers/v1/GetCustomersTests.cs new file mode 100644 index 00000000..39b63009 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/GettingCustomers/v1/GetCustomersTests.cs @@ -0,0 +1,63 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomers.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FluentAssertions; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests.Customers.Features.GettingCustomers.v1; + +public class GetCustomersTests : CustomerServiceIntegrationTestBase +{ + public GetCustomersTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task can_get_existing_customers_list_from_db() + { + // Arrange + var fakeCustomers = new FakeCustomerReadModel().Generate(3); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomers.ToArray()); + + // Act + var query = new GetCustomers(); + var listResult = (await SharedFixture.SendAsync(query)).Customers; + + // Assert + listResult.Should().NotBeNull(); + listResult.Items.Should().NotBeEmpty(); + listResult.Items.Should().HaveCount(3); + listResult.PageNumber.Should().Be(1); + listResult.PageSize.Should().Be(10); + listResult.TotalCount.Should().Be(3); + + listResult.Items.Should().BeEquivalentTo(fakeCustomers, options => options.ExcludingMissingMembers()); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + internal async Task can_get_existing_customers_list_with_correct_page_size_and_page() + { + // Arrange + var fakeCustomers = new FakeCustomerReadModel().Generate(3); + await SharedFixture.InsertMongoDbContextAsync(fakeCustomers.ToArray()); + + // Act + var query = new GetCustomers() { PageNumber = 1, PageSize = 2 }; + var listResult = (await SharedFixture.SendAsync(query)).Customers; + + // Assert + listResult.Should().NotBeNull(); + listResult.Items.Should().NotBeEmpty(); + listResult.Items.Should().HaveCount(2); + listResult.PageNumber.Should().Be(1); + listResult.PageSize.Should().Be(2); + listResult.TotalCount.Should().Be(3); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/FoodDelivery.Services.Customers.IntegrationTests.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/FoodDelivery.Services.Customers.IntegrationTests.csproj new file mode 100644 index 00000000..626b85e0 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/FoodDelivery.Services.Customers.IntegrationTests.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs new file mode 100644 index 00000000..8ff835a3 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs @@ -0,0 +1,17 @@ +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using Tests.Shared.Fixtures; + +namespace FoodDelivery.Services.Customers.IntegrationTests; + +// https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions +[CollectionDefinition(Name)] +public class IntegrationTestCollection + : ICollectionFixture< + SharedFixtureWithEfCoreAndMongo + >, + ICollectionFixture +{ + public const string Name = "Integration Test"; +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestConfigurations.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestConfigurations.cs new file mode 100644 index 00000000..1bda68b3 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestConfigurations.cs @@ -0,0 +1,12 @@ +using Tests.Shared; +using Tests.Shared.Fixtures; + +namespace FoodDelivery.Services.Customers.IntegrationTests; + +public class IntegrationTestConfigurations : TestConfigurations +{ + public IntegrationTestConfigurations() + { + this["ASPNETCORE_ENVIRONMENT"] = "test"; + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs new file mode 100644 index 00000000..af611230 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs @@ -0,0 +1,56 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests.RestockSubscriptions.Features.CreatingRestockSubscription.v1; + +public class CreateRestockSubscriptionTests : CustomerServiceIntegrationTestBase +{ + public CreateRestockSubscriptionTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) { } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task can_create_new_customer_restock_subscription_in_postgres_db() + { + // Arrange + var fakeProduct = CustomersServiceMockServersFixture.CatalogsServiceMock.SetupGetProductById().Response.Product; + var fakeCustomer = new FakeCustomer().Generate(); + await SharedFixture.InsertEfDbContextAsync(fakeCustomer); + + var command = new CreateRestockSubscription( + fakeCustomer.Id, + fakeProduct.Id, + fakeCustomer.Email.Value ?? string.Empty + ); + + // Act + var createdCustomerSubscriptionResponse = await SharedFixture.SendAsync(command); + + // Assert + createdCustomerSubscriptionResponse.RestockSubscriptionId.Should().BeGreaterThan(0); + createdCustomerSubscriptionResponse.RestockSubscriptionId.Should().Be(command.Id); + + var createdRestockSubscription = await SharedFixture.ExecuteEfDbContextAsync( + async db => + await db.RestockSubscriptions.SingleOrDefaultAsync( + x => x.Id == createdCustomerSubscriptionResponse.RestockSubscriptionId + ) + ); + + createdRestockSubscription.Should().NotBeNull(); + createdRestockSubscription!.Email.Value.Should().Be(command.Email); + createdRestockSubscription.ProductInformation.Id.Value.Should().Be(command.ProductId); + createdRestockSubscription.CustomerId.Value.Should().Be(command.CustomerId); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs new file mode 100644 index 00000000..58721519 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs @@ -0,0 +1,143 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; +using FoodDelivery.Services.Shared.Customers.Customers.Events.v1.Integration; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Tests.Shared.Fixtures; +using Tests.Shared.XunitCategories; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Customers.IntegrationTests.Users.Features.RegisteringUser.v1.Events.External; + +public class UserRegisteredTests : CustomerServiceIntegrationTestBase +{ + private static UserRegisteredV1 _userRegistered = default!; + + public UserRegisteredTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + _userRegistered = new FakeUserRegisteredV1().Generate(); + + CustomersServiceMockServersFixture.IdentityServiceMock.SetupGetUserByEmail(_userRegistered); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_consume_by_existing_consumer_through_the_broker() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, null, CancellationToken); + + // Assert + await SharedFixture.WaitForConsuming(); + } + + // [Fact] + // [CategoryTrait(TestCategory.Integration)] + // public async Task should_consume_by_new_consumers_through_broker() + // { + // // Arrange + // var shouldConsume = await IntegrationTestFixture.ShouldConsumeWithNewConsumer(); + // + // // Act + // await IntegrationTestFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + // + // // Assert + // await shouldConsume.Validate(60.Seconds()); + // } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_consume_by_user_registered_consumer_through_the_broker() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.WaitForConsuming(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_create_new_customer_in_postgres_write_db_when_after_consuming_message() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.WaitUntilConditionMet(async () => + { + var existsCustomer = await SharedFixture.ExecuteEfDbContextAsync(async ctx => + { + var res = await ctx.Customers.AnyAsync(x => x.Email.Value == _userRegistered.Email); + + return res; + }); + + return existsCustomer; + }); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_save_mongo_customer_read_model_in_internal_persistence_message_after_consuming_message() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.ShouldProcessedPersistInternalCommand(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_create_new_mongo_customer_read_model_in_the_mongodb_after_consuming_message() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.WaitUntilConditionMet(async () => + { + var existsCustomer = await SharedFixture.ExecuteMongoDbContextAsync(async ctx => + { + var res = await ctx.Customers.AsQueryable().AnyAsync(x => x.Email == _userRegistered.Email); + + return res; + }); + + return existsCustomer; + }); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_save_customer_created_integration_event_in_the_outbox_after_consuming_message() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.ShouldProcessedOutboxPersistMessage(); + } + + [Fact] + [CategoryTrait(TestCategory.Integration)] + public async Task should_publish_customer_created_integration_event_after_consuming_message() + { + // Act + await SharedFixture.PublishMessageAsync(_userRegistered, cancellationToken: CancellationToken); + + // Assert + await SharedFixture.WaitForPublishing(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/XunitMetadata.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/xunit.runner.json b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/FoodDelivery.Services.Customers.LoadTests.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/FoodDelivery.Services.Customers.LoadTests.csproj new file mode 100644 index 00000000..6a0e369e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/FoodDelivery.Services.Customers.LoadTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/XunitMetadata.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/xunit.runner.json b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.LoadTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomer.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomer.cs new file mode 100644 index 00000000..6f5c7f20 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomer.cs @@ -0,0 +1,19 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; + +// Note: AutoBogus doesn't generate values for readonly properties (propertyInfo.CanWrite == false in reflection) +// Note that, should a rule set be used to generate a type, then only members not defined in the rule set are auto generated. +// https://github.com/nickdodd79/AutoBogus#autofakert +// `Faker` has a problem with non-default constructor but `AutoFaker` works also with none-default constructor +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with `CustomInstantiator` and we should exclude theme one by one, so it is better we use Faker<> +internal sealed class FakeCreateCustomer : AutoFaker +{ + public FakeCreateCustomer(string? email = null) + { + long id = 1; + RuleFor(x => x.Email, f => email ?? f.Internet.Email()); + RuleFor(x => x.Id, f => id++); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomerRead.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomerRead.cs new file mode 100644 index 00000000..edd1e6ae --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeCreateCustomerRead.cs @@ -0,0 +1,13 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; + +public sealed class FakeCreateCustomerRead : AutoFaker +{ + public FakeCreateCustomerRead() + { + long id = 1; + RuleFor(x => x.CustomerId, f => id++); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomer.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomer.cs new file mode 100644 index 00000000..f5311dff --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomer.cs @@ -0,0 +1,24 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; + +// Note: AutoBogus doesn't generate values for readonly properties (propertyInfo.CanWrite == false in reflection) +// Auto Faker works only for public constructors +// Note that, should a rule set be used to generate a type, then only members not defined in the rule set are auto generated. +// https://github.com/nickdodd79/AutoBogus#autofakert +// `Faker` has a problem with non-default constructor but `AutoFaker` works also with none-default constructor +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with `CustomInstantiator` and we should exclude theme one by one, so it is better we use Faker<> +public sealed class FakeUpdateCustomer : AutoFaker +{ + public FakeUpdateCustomer(long customerId) + { + RuleFor(x => x.Email, f => f.Internet.Email()); + RuleFor(x => x.Id, customerId); + RuleFor(x => x.FirstName, f => f.Name.FirstName()); + RuleFor(x => x.LastName, f => f.Name.LastName()); + RuleFor(x => x.PhoneNumber, f => f.Phone.PhoneNumber("(+##)##########")); + RuleFor(x => x.BirthDate, DateTime.Now.AddYears(-20)); + RuleFor(x => x.Nationality, "IR"); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomerRead.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomerRead.cs new file mode 100644 index 00000000..32199c1a --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Commands/FakeUpdateCustomerRead.cs @@ -0,0 +1,13 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; + +internal sealed class FakeUpdateCustomerRead : AutoFaker +{ + public FakeUpdateCustomerRead() + { + long id = 1; + RuleFor(x => x.CustomerId, f => id++); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomer.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomer.cs new file mode 100644 index 00000000..dbb3ba58 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomer.cs @@ -0,0 +1,77 @@ +using AutoBogus; +using Bogus; +using BuildingBlocks.Core.Domain.ValueObjects; +using FoodDelivery.Services.Customers.Customers.Models; +using FoodDelivery.Services.Customers.Customers.ValueObjects; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; + +// Note: AutoBogus doesn't generate values for readonly properties (propertyInfo.CanWrite == false in reflection) +// https://github.com/nickdodd79/AutoBogus/blob/8c182f937b65719e7b59bc479546caf3a97fc135/src/AutoBogus/AutoMember.cs#L28 +// https://github.com/nickdodd79/AutoBogus/blob/8c182f937b65719e7b59bc479546caf3a97fc135/src/AutoBogus/AutoBinder.cs#L92 + +// Auto Faker works only for public constructors +// Note that, should a rule set be used to generate a type, then only members not defined in the rule set are auto generated. +// https://github.com/nickdodd79/AutoBogus#autofakert +// `Faker` has a problem with non-default constructor but `AutoFaker` works also with none-default constructor +// because AutoFaker generate data also for private set and init members (not read only get) it doesn't work properly with `CustomInstantiator` and we should exclude theme one by one, so it is better we use Faker<> +public sealed class FakeCustomer : Faker +{ + public FakeCustomer() + { + long id = 1; + + // we should not instantiate customer aggregate manually because it is possible we break aggregate invariant in creating a customer, and it is better we + // create a customer with its factory method + CustomInstantiator(f => + { + var firstName = f.Name.FirstName(); + var lastName = f.Name.LastName(); + + return Customer.Create( + CustomerId.Of(id++), + Email.Of(f.Internet.Email(firstName, lastName)), + PhoneNumber.Of(f.Phone.PhoneNumber("(+##)##########")), + CustomerName.Of(firstName, lastName), + Guid.NewGuid(), + Address.Of( + f.Address.Country(), + f.Address.City(), + f.Address.FullAddress(), + PostalCode.Of(f.Address.ZipCode()) + ), + BirthDate.Of(DateTime.Now.AddYears(-20)), + Nationality.Of("IR") + ); + }); + } + + public FakeCustomer(long id, Guid identityId) + { + // we should not instantiate customer aggregate manually because it is possible we break aggregate invariant in creating a customer, and it is better we + // create a customer with its factory method + CustomInstantiator(f => + { + var firstName = f.Name.FirstName(); + var lastName = f.Name.LastName(); + + var customer = Customer.Create( + CustomerId.Of(id), + Email.Of(f.Internet.Email(firstName, lastName)), + PhoneNumber.Of(f.Phone.PhoneNumber("(+##)##########")), + CustomerName.Of(firstName, lastName), + identityId, + Address.Of( + f.Address.Country(), + f.Address.City(), + f.Address.FullAddress(), + PostalCode.Of(f.Address.ZipCode()) + ), + BirthDate.Of(DateTime.Now.AddYears(-20)), + Nationality.Of("IR") + ); + + return customer; + }); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomerReadModel.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomerReadModel.cs new file mode 100644 index 00000000..e0917e77 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Entities/FakeCustomerReadModel.cs @@ -0,0 +1,22 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Models.Reads; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; + +public sealed class FakeCustomerReadModel : AutoFaker +{ + public FakeCustomerReadModel() + { + int customerId = 1; + RuleFor(x => x.FirstName, (f, u) => f.Name.FirstName()) + .RuleFor(u => u.LastName, (f, u) => f.Name.LastName()) + .RuleFor(u => u.FullName, (f, u) => f.Name.FullName()) + .RuleFor(u => u.Email, (f, u) => f.Internet.Email(u.FirstName, u.LastName)) + .RuleFor(u => u.PhoneNumber, (f, u) => f.Phone.PhoneNumber("(+##)##########")) + .RuleFor(u => u.City, (f, u) => f.Address.City()) + .RuleFor(u => u.Country, (f, u) => f.Address.Country()) + .RuleFor(u => u.DetailAddress, (f, u) => f.Address.FullAddress()) + .RuleFor(u => u.BirthDate, (f, u) => DateTime.Now) + .RuleFor(u => u.CustomerId, (f, u) => customerId++); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/AutoFakeUserRegisteredV1.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/AutoFakeUserRegisteredV1.cs new file mode 100644 index 00000000..3a154f3d --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/AutoFakeUserRegisteredV1.cs @@ -0,0 +1,13 @@ +using AutoBogus; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; + +// beside of auto-generated fields data we can set rules for some fields +public sealed class AutoFakeUserRegisteredV1 : AutoFaker +{ + public AutoFakeUserRegisteredV1() + { + RuleFor(r => r.Roles, r => new List { "user" }); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/FakeUserRegisteredV1.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/FakeUserRegisteredV1.cs new file mode 100644 index 00000000..0ee74d08 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Events/FakeUserRegisteredV1.cs @@ -0,0 +1,24 @@ +using Bogus; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; + +//https://github.com/bchavez/Bogus#using-fakert-inheritance +public sealed class FakeUserRegisteredV1 : Faker +{ + public FakeUserRegisteredV1() + { + CustomInstantiator( + f => + new UserRegisteredV1( + Guid.NewGuid(), + f.Person.Email, + f.Phone.PhoneNumber("(+##)##########"), + f.Person.UserName, + f.Person.FirstName, + f.Person.LastName, + new List { "user" } + ) + ); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Requests/FakeCreateCustomerRequest.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Requests/FakeCreateCustomerRequest.cs new file mode 100644 index 00000000..b9a58b46 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Requests/FakeCreateCustomerRequest.cs @@ -0,0 +1,12 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Requests; + +public sealed class FakeCreateCustomerRequest : AutoFaker +{ + public FakeCreateCustomerRequest(string? email = null) + { + RuleFor(x => x.Email, f => email ?? f.Internet.Email()); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Tests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Tests.cs new file mode 100644 index 00000000..68d894bf --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Customers/Tests.cs @@ -0,0 +1,131 @@ +using Bogus; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Customers; + +public class Tests +{ + public class Events + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void auto_fake_user_registered_v1_test() + { + var userRegistered = new AutoFakeUserRegisteredV1().Generate(1).First(); + userRegistered.IdentityId.Should().NotBeEmpty(); + userRegistered.UserName.Should().NotBeEmpty(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_user_registered_v1_test() + { + var userRegistered = new FakeUserRegisteredV1().Generate(1).First(); + userRegistered.IdentityId.Should().NotBeEmpty(); + userRegistered.UserName.Should().NotBeEmpty(); + } + } + + public class Dtos { } + + public class Entities + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_customer_test() + { + var customers = new FakeCustomer().Generate(5); + customers.All(x => x.Id > 0).Should().BeTrue(); + customers.All(x => x.IdentityId != Guid.Empty).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Email)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Name.FirstName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Name.LastName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Name.FullName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Nationality.Value)).Should().BeTrue(); + customers.All(x => x.BirthDate.Value != default).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Address.City)).Should().BeTrue(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_customer_with_customerId_test() + { + var id = new Faker().Random.Number(1, 100); + var identityId = Guid.NewGuid(); + + var customer = new FakeCustomer(id, identityId).Generate(); + customer.Id.Value.Should().Be(id); + customer.IdentityId.Should().Be(identityId); + customer.Nationality.Should().NotBeNull(); + customer.BirthDate.Should().NotBeNull(); + customer.Address.Should().NotBeNull(); + customer.Email.Should().NotBeNull(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_customer_read_model_test() + { + var customers = new FakeCustomerReadModel().Generate(5); + customers.All(x => x.Id != Guid.Empty).Should().BeTrue(); + customers.All(x => x.IdentityId != Guid.Empty).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Email)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.FirstName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.LastName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.FullName)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.City)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Country)).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.DetailAddress)).Should().BeTrue(); + customers.All(x => x.BirthDate is { } && x.BirthDate.Value != default).Should().BeTrue(); + } + } + + public class Queries { } + + public class Commands + { + public class CreateCustomer + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_create_customer_test() + { + var customers = new FakeCreateCustomer().Generate(5); + customers.All(x => x.Id > 0).Should().BeTrue(); + customers.All(x => !string.IsNullOrWhiteSpace(x.Email)).Should().BeTrue(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_create_customer_with_email_test() + { + var customer = new FakeCreateCustomer("test@test.com").Generate(); + customer.Id.Should().BeGreaterThan(0); + customer.Email.Should().NotBeEmpty(); + customer.Email.Should().Be("test@test.com"); + } + } + + public class UpdateCustomer + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_update_customer_test() + { + var id = new Faker().Random.Number(1); + var customer = new FakeUpdateCustomer(id).Generate(); + customer.Id.Should().Be(id); + customer.Email.Should().NotBeEmpty(); + customer.FirstName.Should().NotBeEmpty(); + customer.LastName.Should().NotBeEmpty(); + customer.PhoneNumber.Should().NotBeEmpty(); + } + } + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Entities/FakeRestockSubscriptions.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Entities/FakeRestockSubscriptions.cs new file mode 100644 index 00000000..0e599089 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Entities/FakeRestockSubscriptions.cs @@ -0,0 +1,28 @@ +using Bogus; +using BuildingBlocks.Core.Domain.ValueObjects; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Products; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Write; +using FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.Entities; + +public sealed class FakeRestockSubscriptions : Faker +{ + public FakeRestockSubscriptions() + { + long id = 1; + + // we should not instantiate customer aggregate manually because it is possible we break aggregate invariant in creating a customer, and it is better we + // create a customer with its factory method + CustomInstantiator( + f => + RestockSubscription.Create( + RestockSubscriptionId.Of(id++), + CustomerId.Of(id++), + ProductInformation.Of(ProductId.Of(f.Random.Number(1, 100)), f.Commerce.ProductName()), + Email.Of(f.Internet.Email()) + ) + ); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Tests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Tests.cs new file mode 100644 index 00000000..33546cd3 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/Tests.cs @@ -0,0 +1,37 @@ +using FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.Entities; +using FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.ValueObjects; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions; + +public class Tests +{ + public class Entities + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_restocksubscription_test() + { + var restockSubscriptions = new FakeRestockSubscriptions().Generate(5); + restockSubscriptions.All(x => x.Id > 0).Should().BeTrue(); + restockSubscriptions.All(x => x.CustomerId > 0).Should().BeTrue(); + restockSubscriptions.All(x => !string.IsNullOrWhiteSpace(x.Email)).Should().BeTrue(); + restockSubscriptions.All(x => !string.IsNullOrWhiteSpace(x.ProductInformation.Name)).Should().BeTrue(); + restockSubscriptions.All(x => x.ProductInformation.Id.Value > 0).Should().BeTrue(); + } + } + + public class ValueObjects + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_product_information_test() + { + var productInformation = new FakeProductInformation().Generate(); + productInformation.Id.Should().NotBeNull(); + productInformation.Id.Value.Should().BeGreaterThan(0); + productInformation.Name.Should().NotBeNullOrEmpty(); + } + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/ValueObjects/FakeProductInformation.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/ValueObjects/FakeProductInformation.cs new file mode 100644 index 00000000..ae17166f --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/RestockSubscriptions/ValueObjects/FakeProductInformation.cs @@ -0,0 +1,13 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Products; +using FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.ValueObjects; + +public sealed class FakeProductInformation : AutoFaker +{ + public FakeProductInformation() + { + RuleFor(x => x.Id, f => ProductId.Of(f.Random.Number(1, 100))); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeProductDto.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeProductDto.cs new file mode 100644 index 00000000..c87c2170 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeProductDto.cs @@ -0,0 +1,32 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; + +//https://github.com/bchavez/Bogus#the-great-c-example +//https://github.com/bchavez/Bogus#bogus-api-support +//https://github.com/nickdodd79/AutoBogus/issues/99 +public sealed class FakeProductDto : AutoFaker +{ + public FakeProductDto() + { + long id = 1; + RuleFor(x => x.ProductStatus, f => f.PickRandom()) + //https://github.com/nickdodd79/AutoBogus/issues/99 + .RuleForType(typeof(int), faker => faker.Random.Int(min: 1, max: int.MaxValue)) + .RuleForType(typeof(long), faker => faker.Random.Long(min: 1, max: long.MaxValue)) + .RuleFor(x => x.Name, f => f.Commerce.ProductName()) + .RuleFor(x => x.Description, f => f.Commerce.ProductDescription()) + .RuleFor(x => x.Id, f => id++) + .RuleFor(x => x.BrandId, f => f.Random.Number(1, 100)) + .RuleFor(x => x.CategoryId, f => f.Random.Number(1, 100)) + .RuleFor(x => x.SupplierId, f => f.Random.Number(1, 100)) + .RuleFor(x => x.BrandName, f => f.Company.CompanyName()) + .RuleFor(x => x.CategoryName, f => f.Commerce.Categories(1).First()) + .RuleFor(x => x.SupplierName, f => f.Name.FullName()) + .RuleFor(x => x.Price, f => decimal.Parse(f.Commerce.Price())) + .RuleFor(x => x.AvailableStock, f => f.Random.Number(10, 100)) + .RuleFor(x => x.RestockThreshold, f => f.Random.Number(5)) + .RuleFor(x => x.MaxStockThreshold, f => f.Random.Number(100)); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeUserIdentityDto.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeUserIdentityDto.cs new file mode 100644 index 00000000..97e74cb1 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Dtos/FakeUserIdentityDto.cs @@ -0,0 +1,18 @@ +using AutoBogus; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; + +//https://github.com/bchavez/Bogus#the-great-c-example +//https://github.com/bchavez/Bogus#bogus-api-support +public sealed class FakeUserIdentityDto : AutoFaker +{ + public FakeUserIdentityDto(string? email = null) + { + RuleFor(u => u.FirstName, (f, u) => f.Name.FirstName()) + .RuleFor(u => u.LastName, (f, u) => f.Name.LastName()) + .RuleFor(u => u.UserName, (f, u) => f.Internet.UserName(u.FirstName, u.LastName)) + .RuleFor(u => u.Email, (f, u) => email ?? f.Internet.Email(u.FirstName, u.LastName)) + .RuleFor(u => u.PhoneNumber, (f, u) => email ?? f.Phone.PhoneNumber("(+##)##########")); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs new file mode 100644 index 00000000..79d9f64e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs @@ -0,0 +1,48 @@ +using System.Net.Http.Json; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using FluentAssertions; +using Tests.Shared.Helpers; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; + +public class CatalogServiceMockTests +{ + private readonly CatalogsServiceMock _catalogsServiceMock; + + public CatalogServiceMockTests() + { + _catalogsServiceMock = CatalogsServiceMock.Start(ConfigurationHelper.BindOptions()); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public async Task root_address() + { + var client = new HttpClient { BaseAddress = new Uri(_catalogsServiceMock.Url!) }; + var res = await client.GetAsync("/"); + res.EnsureSuccessStatusCode(); + + var g = await res.Content.ReadAsStringAsync(); + g.Should().NotBeEmpty(); + g.Should().Be("Catalogs Service!"); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public async Task get_by_id() + { + var (response, endpoint) = _catalogsServiceMock.SetupGetProductById(); + var fakeProduct = response.Product; + + var client = new HttpClient { BaseAddress = new Uri(_catalogsServiceMock.Url!) }; + var httpResponse = await client.GetAsync(endpoint); + + await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); + var data = await httpResponse.Content.ReadFromJsonAsync(); + data.Should().NotBeNull(); + data!.Product.Should().BeEquivalentTo(fakeProduct, options => options.ExcludingMissingMembers()); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs new file mode 100644 index 00000000..487efc83 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs @@ -0,0 +1,61 @@ +using System.Net; +using FoodDelivery.Services.Customers.Products.Models; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Settings; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; + +//https://www.ontestautomation.com/api-mocking-in-csharp-with-wiremock-net/ +public class CatalogsServiceMock : WireMockServer +{ + private CatalogsApiClientOptions CatalogsApiClientOptions { get; init; } = default!; + + protected CatalogsServiceMock(WireMockServerSettings settings) + : base(settings) + { + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + Given(Request.Create().WithPath("/").UsingGet()) // we should put / in the beginning of the endpoint + .RespondWith(Response.Create().WithStatusCode(200).WithBody("Catalogs Service!")); + } + + public static CatalogsServiceMock Start(CatalogsApiClientOptions catalogsApiClientOptions, bool ssl = false) + { + // new WireMockServer() is equivalent to call WireMockServer.Start() + return new CatalogsServiceMock( + new WireMockServerSettings + { + UseSSL = ssl, + // we could use our option url here, but I use random port (Urls = new string[] {} also set a fix port 5000 we should not use this if we want a random port) + // Urls = new string[]{catalogsApiClientOptions.BaseApiAddress} + } + ) + { + CatalogsApiClientOptions = catalogsApiClientOptions + }; + } + + public (GetProductByIdClientDto Response, string Endpoint) SetupGetProductById(long id = 0) + { + var fakeProduct = new FakeProductDto().Generate(1).First(); + if (id > 0) + fakeProduct = fakeProduct with { Id = id }; + + fakeProduct = fakeProduct with { AvailableStock = 0 }; + + var response = new GetProductByIdClientDto(fakeProduct); + + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + // we should put / in the beginning of the endpoint + var endpointPath = $"/{CatalogsApiClientOptions.ProductsEndpoint}/{fakeProduct.Id}"; + + Given(Request.Create().UsingGet().WithPath(endpointPath)) + .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); + + return (response, endpointPath); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs new file mode 100644 index 00000000..aa342cc5 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs @@ -0,0 +1,86 @@ +using System.Net; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Settings; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; + +//https://www.ontestautomation.com/api-mocking-in-csharp-with-wiremock-net/ +//https://github.com/WireMock-Net/WireMock.Net/wiki +//https://pcholko.com/posts/2021-04-05/wiremock-integration-test/ +//https://www.youtube.com/watch?v=YU3ohofu6UU +public class IdentityServiceMock : WireMockServer +{ + private IdentityApiClientOptions IdentityApiClientOptions { get; init; } = default!; + + private IdentityServiceMock(WireMockServerSettings settings) + : base(settings) + { + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + Given(Request.Create().WithPath("/").UsingGet()) // we should put / in the beginning of the endpoint + .RespondWith(Response.Create().WithStatusCode(200).WithBody("Identity Service!")); + } + + public static IdentityServiceMock Start(IdentityApiClientOptions identityApiClientOptions, bool ssl = false) + { + // new WireMockServer() is equivalent to call WireMockServer.Start() + var mock = new IdentityServiceMock( + new WireMockServerSettings + { + UseSSL = ssl, + // we could use our option url here, but I use random port (Urls = new string[] {} also set a fix port 5000 we should not use this if we want a random port) + // Urls = new string[] {identityApiClientOptions.BaseApiAddress} + } + ) + { + IdentityApiClientOptions = identityApiClientOptions + }; + + return mock; + } + + public (GetUserByEmailClientDto Response, string Endpoint) SetupGetUserByEmail(string? email = null) + { + var fakeIdentityUser = new FakeUserIdentityDto().Generate(); + if (!string.IsNullOrWhiteSpace(email)) + fakeIdentityUser = fakeIdentityUser with { Email = email }; + + var response = new GetUserByEmailClientDto(fakeIdentityUser); + + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + // we should put / in the beginning of the endpoint + var endpointPath = $"/{IdentityApiClientOptions.UsersEndpoint}/by-email/{fakeIdentityUser.Email}"; + + Given(Request.Create().UsingGet().WithPath(endpointPath)) + .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); + + return (response, endpointPath); + } + + public (GetUserByEmailClientDto Response, string Endpoint) SetupGetUserByEmail(UserRegisteredV1 userRegisteredV1) + { + var response = new GetUserByEmailClientDto( + new IdentityUserClientDto( + userRegisteredV1.IdentityId, + userRegisteredV1.UserName, + userRegisteredV1.Email, + userRegisteredV1.PhoneNumber, + userRegisteredV1.FirstName, + userRegisteredV1.LastName + ) + ); + + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + var endpointPath = $"/{IdentityApiClientOptions.UsersEndpoint}/by-email/{userRegisteredV1.Email}"; // we should put / in the beginning of the endpoint + + Given(Request.Create().UsingGet().WithPath(endpointPath)) + .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); + + return (response, endpointPath); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs new file mode 100644 index 00000000..70574512 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Json; +using BuildingBlocks.Web.Extensions; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; +using FluentAssertions; +using Tests.Shared.Helpers; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; + +public class IdentityServiceMockTests +{ + private readonly IdentityServiceMock _identityServiceMock; + + public IdentityServiceMockTests() + { + _identityServiceMock = IdentityServiceMock.Start(ConfigurationHelper.BindOptions()); + } + + [Fact] + public async Task root_address() + { + var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var res = await client.GetAsync("/"); + res.EnsureSuccessStatusCode(); + + var g = await res.Content.ReadAsStringAsync(); + g.Should().NotBeEmpty(); + g.Should().Be("Identity Service!"); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public async Task get_by_email() + { + var (response, endpoint) = _identityServiceMock.SetupGetUserByEmail(); + var fakeIdentityUser = response.UserIdentity; + + var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var httpResponse = await client.GetAsync(endpoint); + + await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); + var data = await httpResponse.Content.ReadFromJsonAsync(); + data.Should().NotBeNull(); + data!.UserIdentity.Should().BeEquivalentTo(fakeIdentityUser, options => options.ExcludingMissingMembers()); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public async Task get_by_email_and_user_registered() + { + var userRegistered = new FakeUserRegisteredV1().Generate(); + var (response, endpoint) = _identityServiceMock.SetupGetUserByEmail(userRegistered); + var fakeIdentityUser = response.UserIdentity; + + var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var res = await client.GetAsync(endpoint); + res.EnsureSuccessStatusCode(); + + var g = await res.Content.ReadFromJsonAsync(); + g.Should().NotBeNull(); + g!.UserIdentity.Should().BeEquivalentTo(fakeIdentityUser, options => options.ExcludingMissingMembers()); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Tests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Tests.cs new file mode 100644 index 00000000..a09fd151 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Tests.cs @@ -0,0 +1,26 @@ +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_user_identity_dto_test() + { + var userIdentityDto = new FakeUserIdentityDto().Generate(1).First(); + userIdentityDto.Id.Should().NotBeEmpty(); + userIdentityDto.UserName.Should().NotBeEmpty(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void fake_product_dto_test() + { + var productDto = new FakeProductDto().Generate(1).First(); + productDto.Id.Should().BeGreaterThan(0); + productDto.Name.Should().NotBeEmpty(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs new file mode 100644 index 00000000..a077a27a --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs @@ -0,0 +1,28 @@ +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; +using Tests.Shared.Helpers; + +namespace FoodDelivery.Services.Customers.TestShared.Fixtures; + +public class CustomersServiceMockServersFixture : IAsyncLifetime +{ + public static IdentityServiceMock IdentityServiceMock { get; private set; } = default!; + public static CatalogsServiceMock CatalogsServiceMock { get; private set; } = default!; + + public Task InitializeAsync() + { + IdentityServiceMock = IdentityServiceMock.Start(ConfigurationHelper.BindOptions()); + CatalogsServiceMock = CatalogsServiceMock.Start(ConfigurationHelper.BindOptions()); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + IdentityServiceMock.Dispose(); + CatalogsServiceMock.Dispose(); + + return Task.CompletedTask; + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/FoodDelivery.Services.Customers.TestShared.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/FoodDelivery.Services.Customers.TestShared.csproj new file mode 100644 index 00000000..0371f171 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/FoodDelivery.Services.Customers.TestShared.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs new file mode 100644 index 00000000..feca815c --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs @@ -0,0 +1,71 @@ +using AutoMapper; +using BuildingBlocks.Resiliency; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.TestShared.Fixtures; +using Microsoft.Extensions.Options; +using Tests.Shared.Helpers; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Common; + +[CollectionDefinition(nameof(QueryTestCollection))] +public class QueryTestCollection : ICollectionFixture { } + +//https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection +[Collection(UnitTestCollection.Name)] +[CategoryTrait(TestCategory.Unit)] +public class CustomerServiceUnitTestBase : IAsyncDisposable +{ + // We don't need to inject `CustomersServiceMockServersFixture` class fixture in the constructor because it initialized by `collection fixture` and its static properties are accessible in the codes + public CustomerServiceUnitTestBase() + { + Mapper = MapperFactory.Create(); + CustomersDbContext = DbContextFactory.Create(); + + //https://stackoverflow.com/questions/40876507/net-core-unit-testing-mock-ioptionst + IOptions identityClientOptions = Options.Create( + ConfigurationHelper.BindOptions() + ); + IOptions policyOptions = Options.Create( + new PolicyOptions + { + RetryCount = 1, + TimeOutDuration = 3, + BreakDuration = 5 + } + ); + IdentityApiClient = new IdentityApiClient( + new HttpClient { BaseAddress = new Uri(CustomersServiceMockServersFixture.IdentityServiceMock.Url!) }, + Mapper, + identityClientOptions, + policyOptions + ); + + //https://stackoverflow.com/questions/40876507/net-core-unit-testing-mock-ioptionst + IOptions catalogClientOptions = Options.Create( + ConfigurationHelper.BindOptions() + ); + CatalogApiClient = new CatalogApiClient( + new HttpClient { BaseAddress = new Uri(CustomersServiceMockServersFixture.CatalogsServiceMock.Url!) }, + Mapper, + catalogClientOptions, + policyOptions + ); + } + + public IMapper Mapper { get; } + public CustomersDbContext CustomersDbContext { get; } + public IdentityApiClient IdentityApiClient { get; } + public CatalogApiClient CatalogApiClient { get; } + + public async ValueTask DisposeAsync() + { + await DbContextFactory.Destroy(CustomersDbContext); + // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests + CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); + CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/DbContextFactory.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/DbContextFactory.cs new file mode 100644 index 00000000..0a65586a --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/DbContextFactory.cs @@ -0,0 +1,31 @@ +using BuildingBlocks.Core.Persistence.EfCore; +using BuildingBlocks.Core.Persistence.EfCore.Interceptors; +using FoodDelivery.Services.Customers.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FoodDelivery.Services.Customers.UnitTests.Common; + +public static class DbContextFactory +{ + public static CustomersDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + // ref: https://andrewlock.net/series/using-strongly-typed-entity-ids-to-avoid-primitive-obsession/ + .ReplaceService>() + .AddInterceptors(new AuditInterceptor(), new SoftDeleteInterceptor(), new ConcurrencyInterceptor()) + .Options; + + var context = new CustomersDbContext(options); + context.Database.EnsureCreated(); + + return context; + } + + public static async Task Destroy(CustomersDbContext context) + { + await context.Database.EnsureDeletedAsync(); + await context.DisposeAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MapperFactory.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MapperFactory.cs new file mode 100644 index 00000000..4f31c437 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MapperFactory.cs @@ -0,0 +1,19 @@ +using AutoMapper; +using FoodDelivery.Services.Customers.Customers; +using FoodDelivery.Services.Customers.RestockSubscriptions; + +namespace FoodDelivery.Services.Customers.UnitTests.Common; + +public static class MapperFactory +{ + public static IMapper Create() + { + var configurationProvider = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + + return configurationProvider.CreateMapper(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MappingFixture.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MappingFixture.cs new file mode 100644 index 00000000..66b535a5 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/MappingFixture.cs @@ -0,0 +1,13 @@ +using AutoMapper; + +namespace FoodDelivery.Services.Customers.UnitTests.Common; + +public class MappingFixture +{ + public MappingFixture() + { + Mapper = MapperFactory.Create(); + } + + public IMapper Mapper { get; } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/CustomersMappingTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/CustomersMappingTests.cs new file mode 100644 index 00000000..12c08fd9 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/CustomersMappingTests.cs @@ -0,0 +1,81 @@ +using AutoBogus; +using AutoMapper; +using FoodDelivery.Services.Customers.Customers.Dtos.v1; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers; + +public class CustomersMappingTests : IClassFixture +{ + private readonly IMapper _mapper; + + public CustomersMappingTests(MappingFixture fixture) + { + _mapper = fixture.Mapper; + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_configuration() + { + _mapper.ConfigurationProvider.AssertConfigurationIsValid(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_customer_read_model_to_customer_read_dto() + { + var customerReadModel = AutoFaker.Generate(); + var res = _mapper.Map(customerReadModel); + customerReadModel.CustomerId.Should().Be(res.CustomerId); + customerReadModel.FullName.Should().Be(res.Name); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_customer_to_create_mongo_customer_read_models() + { + var customer = new FakeCustomer().Generate(); + var res = _mapper.Map(customer); + customer.Id.Value.Should().Be(res.CustomerId); + customer.Name.FullName.Should().Be(res.FullName); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_create_mongo_customer_read_models_to_customer_read_model() + { + var createReadCustomer = AutoFaker.Generate(); + var res = _mapper.Map(createReadCustomer); + createReadCustomer.IdentityId.Should().Be(res.IdentityId); + createReadCustomer.CustomerId.Should().Be(res.CustomerId); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_customer_to_update_mongo_customer_read_model() + { + var customer = new FakeCustomer().Generate(); + var res = _mapper.Map(customer); + customer.Id.Value.Should().Be(res.CustomerId); + customer.Name.FullName.Should().Be(res.FullName); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_update_mongo_customer_reads_model_to_customer_read_model() + { + var updateMongoCustomerReadsModel = AutoFaker.Generate(); + var res = _mapper.Map(updateMongoCustomerReadsModel); + updateMongoCustomerReadsModel.Id.Should().Be(res.Id); + updateMongoCustomerReadsModel.CustomerId.Should().Be(res.CustomerId); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs new file mode 100644 index 00000000..0ec702eb --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs @@ -0,0 +1,134 @@ +using BuildingBlocks.Core.Exception.Types; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FoodDelivery.Services.Customers.Users.Model; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.CreatingCustomer.v1; + +//https://www.testwithspring.com/lesson/the-best-practices-of-nested-unit-tests/ +//https://jeremydmiller.com/2022/10/24/using-context-specification-to-better-express-complicated-tests/ +//{do_something}_{given_some_condition} + +// totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern +// https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ +public class CreateCustomerTests : CustomerServiceUnitTestBase +{ + private readonly ILogger _logger; + private readonly IIdentityApiClient _identityApiClient; + + public CreateCustomerTests() + { + _logger = new NullLogger(); + _identityApiClient = Substitute.For(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_create_customer_with_valid_inputs() + { + // Arrange + // we can mock `IdentityApiClient` with `nsubstitute` or we can use `wiremock` server and its endpoint setup for getting response(that is in base CustomerServiceUnitTestBase class) + + // 1) using mocker server approach in unit test for getting response from `IdentityApiClient`, and using `base` class `IdentityApiClient` property for using mock server + // var fakeIdentityUser = CustomersServiceMockServersFixture.IdentityServiceMock + // .SetupGetUserByEmail() + // .Response.UserIdentity; + + // 2) mocking `IdentityApiClient` for unit test + var fakeIdentityUser = new FakeUserIdentityDto().Generate(); + + var user = Mapper.Map(fakeIdentityUser); + + //https://nsubstitute.github.io/help/return-for-args/ + //https://nsubstitute.github.io/help/set-return-value/ + //https://nsubstitute.github.io/help/argument-matchers/ + _identityApiClient + .GetUserByEmailAsync(Arg.Is(x => x == fakeIdentityUser!.Email), Arg.Any()) + .Returns(user); + + var command = new FakeCreateCustomer(fakeIdentityUser!.Email).Generate(); + var handler = new CreateCustomerHandler(_identityApiClient, CustomersDbContext, _logger); + + // Act + var createdCustomerResponse = await handler.Handle(command, CancellationToken.None); + + // Assert + var entity = await CustomersDbContext.Customers.FindAsync(CustomerId.Of(createdCustomerResponse.CustomerId)); + entity.Should().NotBeNull(); + entity!.Email.Value.Should().Be(command.Email); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_not_found_exception_with_none_exists_user() + { + // Arrange + var command = new CreateCustomer("test@test.com"); + var handler = new CreateCustomerHandler(IdentityApiClient, CustomersDbContext, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should() + .ThrowAsync() + .WithMessage("*") + .Where(e => e.StatusCode == StatusCodes.Status404NotFound); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_argument_exception_with_null_command() + { + // Arrange + var handler = new CreateCustomerHandler(IdentityApiClient, CustomersDbContext, _logger); + + //Act + Func act = async () => + { + await handler.Handle(null!, CancellationToken.None); + }; + + // Assert + await act.Should().ThrowAsync(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_already_exist_exception_with_an_existing_customer() + { + // Arrange + var existCustomer = new FakeCustomer().Generate(); + await CustomersDbContext.Customers.AddAsync(existCustomer); + await CustomersDbContext.SaveChangesAsync(); + + var command = new CreateCustomer(existCustomer.Email); + var handler = new CreateCustomerHandler(IdentityApiClient, CustomersDbContext, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerValidatorTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerValidatorTests.cs new file mode 100644 index 00000000..31d5f50a --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/CreateCustomerValidatorTests.cs @@ -0,0 +1,60 @@ +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using FluentValidation.TestHelper; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.CreatingCustomer.v1; + +public class CreateCustomerValidatorTests : CustomerServiceUnitTestBase +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_inputs() + { + // Arrange + var command = new CreateCustomer("test@example.com"); + var validator = new CreateCustomerValidator(); + + var result = validator.TestValidate(command); + result.IsValid.Should().BeTrue(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_email_input() + { + // Arrange + var command = new CreateCustomer("test@example.com"); + var validator = new CreateCustomerValidator(); + + var result = validator.TestValidate(command); + result.ShouldNotHaveValidationErrorFor(x => x.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_null_or_empty_email() + { + // Arrange + var command = new CreateCustomer(null!); + var validator = new CreateCustomerValidator(); + + var result = validator.TestValidate(command); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_invalid_email() + { + // Arrange + var command = new CreateCustomer("invalid_email"); + var validator = new CreateCustomerValidator(); + + var result = validator.TestValidate(command); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.Email).WithErrorMessage("Email address is invalid."); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs new file mode 100644 index 00000000..8c13b39e --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs @@ -0,0 +1,49 @@ +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.CreatingCustomer.v1.Read; + +public class CreateCustomerTests : CustomerServiceUnitTestBase +{ + private readonly ICustomersReadUnitOfWork _customersReadUnitOfWork; + + public CreateCustomerTests() + { + _customersReadUnitOfWork = Substitute.For(); + var customersReadRepository = Substitute.For(); + _customersReadUnitOfWork.CustomersRepository.Returns(customersReadRepository); + _customersReadUnitOfWork.CommitAsync(Arg.Any()).Returns(Task.CompletedTask); + } + + // totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here + // https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_create_customer_read_with_valid_inputs() + { + // Arrange + var fakeCreateCustomerReadCommand = new FakeCreateCustomerRead().Generate(); + var insertCustomer = Mapper.Map(fakeCreateCustomerReadCommand); + + _customersReadUnitOfWork.CustomersRepository + .AddAsync(Arg.Is(insertCustomer), Arg.Any()) + .Returns(insertCustomer); + var handler = new CreateCustomerReadHandler(Mapper, _customersReadUnitOfWork); + + // Act + var res = await handler.Handle(fakeCreateCustomerReadCommand, CancellationToken.None); + + // Assert + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .AddAsync(Arg.Is(insertCustomer), Arg.Any()); + await _customersReadUnitOfWork.Received(1).CommitAsync(Arg.Any()); + res.Should().NotBeNull(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerByCustomerId/GetCustomerByCustomerIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerByCustomerId/GetCustomerByCustomerIdTests.cs new file mode 100644 index 00000000..ff10e5a8 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerByCustomerId/GetCustomerByCustomerIdTests.cs @@ -0,0 +1,92 @@ +using System.Linq.Expressions; +using Bogus; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomerByCustomerId.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.GettingCustomerByCustomerId; + +public class GetCustomerByCustomerIdTests : CustomerServiceUnitTestBase +{ + private readonly ICustomersReadUnitOfWork _customersReadUnitOfWork; + + public GetCustomerByCustomerIdTests() + { + _customersReadUnitOfWork = Substitute.For(); + var customersReadRepository = Substitute.For(); + _customersReadUnitOfWork.CustomersRepository.Returns(customersReadRepository); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_get_existing_customer_with_valid_input() + { + // Arrange + var customerReadModel = new FakeCustomerReadModel().Generate(); + _customersReadUnitOfWork.CustomersRepository + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(customerReadModel) == true), + Arg.Any() + ) + .Returns(customerReadModel); + + // Act + var query = new GetCustomerByCustomerId(customerReadModel.CustomerId); + var handler = new GetCustomerByCustomerIdHandler(_customersReadUnitOfWork, Mapper); + var res = await handler.Handle(query, CancellationToken.None); + + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(customerReadModel) == true), + Arg.Any() + ); + res.Should().NotBeNull(); + res.Customer.CustomerId.Should().Be(customerReadModel.CustomerId); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throws_notfound_exception_when_record_does_not_exist() + { + // Arrange + var invalidId = new Faker().Random.Number(1, 100); + var query = new GetCustomerByCustomerId(invalidId); + var handler = new GetCustomerByCustomerIdHandler(_customersReadUnitOfWork, Mapper); + + // Act + Func act = async () => _ = await handler.Handle(query, CancellationToken.None); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync( + Arg.Is>>( + exp => + exp.Compile()( + new Customer + { + CustomerId = invalidId, + IdentityId = Guid.NewGuid(), + Email = "", + FirstName = "", + LastName = "", + FullName = "", + PhoneNumber = "", + Created = DateTime.Now + } + ) == true + ), + Arg.Any() + ); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs new file mode 100644 index 00000000..12d637e0 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/GettingCustomerById/v1/GetCustomerByIdTests.cs @@ -0,0 +1,92 @@ +using System.Linq.Expressions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.GettingCustomerById.v1; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.GettingCustomerById.v1; + +public class GetCustomerByIdTests : CustomerServiceUnitTestBase +{ + private readonly ICustomersReadUnitOfWork _customersReadUnitOfWork; + + public GetCustomerByIdTests() + { + _customersReadUnitOfWork = Substitute.For(); + var customersReadRepository = Substitute.For(); + _customersReadUnitOfWork.CustomersRepository.Returns(customersReadRepository); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_get_existing_customer_with_valid_input() + { + // Arrange + var customerReadModel = new FakeCustomerReadModel().Generate(); + _customersReadUnitOfWork.CustomersRepository + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(customerReadModel) == true), + Arg.Any() + ) + .Returns(customerReadModel); + + // Act + var query = new GetCustomerById(customerReadModel.Id); + var handler = new GetCustomerByIdHandler(_customersReadUnitOfWork, Mapper); + var res = await handler.Handle(query, CancellationToken.None); + + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(customerReadModel) == true), + Arg.Any() + ); + res.Should().NotBeNull(); + res.Customer.Id.Should().Be(customerReadModel.Id); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throws_notfound_exception_when_record_does_not_exist() + { + // Arrange + var invalidId = Guid.NewGuid(); + var query = new GetCustomerById(invalidId); + var handler = new GetCustomerByIdHandler(_customersReadUnitOfWork, Mapper); + + // Act + Func act = async () => _ = await handler.Handle(query, CancellationToken.None); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync( + Arg.Is>>( + exp => + exp.Compile()( + new Customer + { + Id = invalidId, + IdentityId = Guid.NewGuid(), + CustomerId = 0, + Email = "", + FirstName = "", + LastName = "", + FullName = "", + PhoneNumber = "", + Created = DateTime.Now + } + ) == true + ), + Arg.Any() + ); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/Read/UpdateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/Read/UpdateCustomerTests.cs new file mode 100644 index 00000000..390253de --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/Read/UpdateCustomerTests.cs @@ -0,0 +1,93 @@ +using System.Linq.Expressions; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.Read.Mongo; +using FoodDelivery.Services.Customers.Customers.Models.Reads; +using FoodDelivery.Services.Customers.Shared.Contracts; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.UpdatingCustomer.v1.Read; + +public class UpdateCustomerTests : CustomerServiceUnitTestBase +{ + private readonly ICustomersReadUnitOfWork _customersReadUnitOfWork; + + public UpdateCustomerTests() + { + _customersReadUnitOfWork = Substitute.For(); + var customersReadRepository = Substitute.For(); + _customersReadUnitOfWork.CustomersRepository.Returns(customersReadRepository); + _customersReadUnitOfWork.CommitAsync(Arg.Any()).Returns(Task.CompletedTask); + } + + // totally we don't need to unit test our handlers according jimmy bogard blogs and videos and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here + // https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_update_customer_read_with_valid_inputs() + { + // Arrange + var fakeUpdateCustomerReadCommand = new FakeUpdateCustomerRead().Generate(); + var existCustomer = new FakeCustomerReadModel().Generate(); + var updateCustomer = Mapper.Map(fakeUpdateCustomerReadCommand); + + _customersReadUnitOfWork.CustomersRepository + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(existCustomer) == true), + Arg.Any() + ) + .Returns(existCustomer); + + _customersReadUnitOfWork.CustomersRepository + .UpdateAsync(Arg.Is(updateCustomer), Arg.Any()) + .Returns(updateCustomer); + var handler = new UpdateCustomerReadHandler(_customersReadUnitOfWork, Mapper); + + // Act + var res = await handler.Handle(fakeUpdateCustomerReadCommand, CancellationToken.None); + + // Assert + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .UpdateAsync(Arg.Is(updateCustomer), Arg.Any()); + await _customersReadUnitOfWork.Received(1).CommitAsync(Arg.Any()); + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync( + Arg.Is>>(exp => exp.Compile()(existCustomer) == true), + Arg.Any() + ); + res.Should().NotBeNull(); + existCustomer.Id.Should().Be(fakeUpdateCustomerReadCommand.Id); + existCustomer.CustomerId.Should().Be(fakeUpdateCustomerReadCommand.CustomerId); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_not_found_exception_when_customer_not_exist() + { + // Arrange + var fakeUpdateCustomerReadCommand = new FakeUpdateCustomerRead().Generate(); + + _customersReadUnitOfWork.CustomersRepository + .FindOneAsync(Arg.Any>>(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var handler = new UpdateCustomerReadHandler(_customersReadUnitOfWork, Mapper); + + // Act + Func act = async () => _ = await handler.Handle(fakeUpdateCustomerReadCommand, CancellationToken.None); + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + + await _customersReadUnitOfWork.CustomersRepository + .Received(1) + .FindOneAsync(Arg.Any>>(), Arg.Any()); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerTests.cs new file mode 100644 index 00000000..40448c79 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerTests.cs @@ -0,0 +1,81 @@ +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.UpdatingCustomer.v1; + +public class UpdateCustomerTests : CustomerServiceUnitTestBase +{ + private readonly ILogger _logger; + + public UpdateCustomerTests() + { + _logger = new NullLogger(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_update_customer_with_valid_inputs() + { + // Arrange + var customerToInsert = new FakeCustomer().Generate(); + await CustomersDbContext.AddAsync(customerToInsert); + await CustomersDbContext.SaveChangesAsync(); + + var command = new FakeUpdateCustomer(customerToInsert.Id).Generate(); + var handler = new UpdateCustomerHandler(CustomersDbContext, _logger); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var entity = await CustomersDbContext.Customers.FindAsync(CustomerId.Of(command.Id)); + entity.Should().NotBeNull(); + entity!.Email.Value.Should().Be(command.Email); + entity!.PhoneNumber.Value.Should().Be(command.PhoneNumber); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_argument_exception_with_null_command() + { + // Arrange + var handler = new UpdateCustomerHandler(CustomersDbContext, _logger); + + //Act + Func act = async () => + { + await handler.Handle(null!, CancellationToken.None); + }; + + // Assert + await act.Should().ThrowAsync(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_not_found_when_input_customer_not_exists() + { + // Arrange + var customerToInsert = new FakeCustomer().Generate(); + + var command = new FakeUpdateCustomer(customerToInsert.Id).Generate(); + var handler = new UpdateCustomerHandler(CustomersDbContext, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerValidatorTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerValidatorTests.cs new file mode 100644 index 00000000..40fc0780 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/UpdatingCustomer/v1/UpdateCustomerValidatorTests.cs @@ -0,0 +1,193 @@ +using Bogus; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using FluentValidation.TestHelper; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Features.UpdatingCustomer.v1; + +public class UpdateCustomerValidatorTests : CustomerServiceUnitTestBase +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_inputs() + { + var updateCommand = new FakeUpdateCustomer(1).Generate(); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeTrue(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_phone_number() + { + var updateCommand = new UpdateCustomer( + 0, + string.Empty, + string.Empty, + string.Empty, + new Faker().Phone.PhoneNumber("(+##)##########") + ); + + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldNotHaveValidationErrorFor(x => x.PhoneNumber); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_invalid_min_lenght_phone_number() + { + var updateCommand = new UpdateCustomer(0, string.Empty, string.Empty, string.Empty, "1235"); + + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result + .ShouldHaveValidationErrorFor(x => x.PhoneNumber) + .WithErrorMessage("PhoneNumber must not be less than 7 characters."); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_invalid_max_lenght_phone_number() + { + var updateCommand = new UpdateCustomer( + 0, + string.Empty, + string.Empty, + string.Empty, + "123555555555555555555555555555" + ); + + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result + .ShouldHaveValidationErrorFor(x => x.PhoneNumber) + .WithErrorMessage("PhoneNumber must not exceed 15 characters."); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_empty_phone_number() + { + var updateCommand = new UpdateCustomer(0, string.Empty, string.Empty, string.Empty, string.Empty); + + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldHaveValidationErrorFor(x => x.PhoneNumber).WithErrorMessage("Phone Number is required."); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_email_input() + { + var updateCommand = new UpdateCustomer(0, string.Empty, string.Empty, "test@emaple.com", string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldNotHaveValidationErrorFor(x => x.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_null_or_empty_email() + { + // Arrange + var updateCommand = new UpdateCustomer(0, string.Empty, string.Empty, string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_fail_with_invalid_email() + { + // Arrange + var updateCommand = new UpdateCustomer(0, string.Empty, string.Empty, "invalid_email", string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.Email).WithErrorMessage("Email address is invalid."); + } + + [Fact] + public void must_fail_with_null_or_empty_firstname() + { + // Arrange + var updateCommand = new UpdateCustomer(0, string.Empty, "stone", string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.FirstName); + } + + [Fact] + public void must_success_with_valid_firstname() + { + // Arrange + var updateCommand = new UpdateCustomer(0, "john", string.Empty, string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldNotHaveValidationErrorFor(x => x.FirstName); + } + + [Fact] + public void must_fail_with_null_or_empty_lastname() + { + // Arrange + var updateCommand = new UpdateCustomer(0, "john", string.Empty, string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.LastName); + } + + [Fact] + public void must_success_with_valid_lastname() + { + // Arrange + var updateCommand = new UpdateCustomer(0, "john", "stone", string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldNotHaveValidationErrorFor(x => x.LastName); + } + + [Fact] + public void must_fail_with_empty_id() + { + // Arrange + var updateCommand = new UpdateCustomer(0, "john", string.Empty, string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.IsValid.Should().BeFalse(); + result.ShouldHaveValidationErrorFor(x => x.Id); + } + + [Fact] + public void must_success_with_valid_id() + { + // Arrange + var updateCommand = new UpdateCustomer(120, "john", string.Empty, string.Empty, string.Empty); + var validator = new UpdateCustomerValidator(); + + var result = validator.TestValidate(updateCommand); + result.ShouldNotHaveValidationErrorFor(x => x.Id); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Models/CustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Models/CustomerTests.cs new file mode 100644 index 00000000..e6d09cef --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Models/CustomerTests.cs @@ -0,0 +1,118 @@ +using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Commands; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.Models.Customer; + +//https://www.testwithspring.com/lesson/the-best-practices-of-nested-unit-tests/ +//https://jeremydmiller.com/2022/10/24/using-context-specification-to-better-express-complicated-tests/ +//{do_something}_{given_some_condition} + +public class CustomerTests +{ + public class CreateCustomer + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_create_customer_with_valid_inputs() + { + // Arrange + var customerInput = new FakeCustomer().Generate(); + + // Act + var createdCustomer = Services.Customers.Customers.Models.Customer.Create( + customerInput.Id, + customerInput.Email, + customerInput.PhoneNumber, + customerInput.Name, + customerInput.IdentityId + ); + + // Assert + createdCustomer.IdentityId.Should().Be(customerInput.IdentityId); + createdCustomer.Id.Should().BeEquivalentTo(customerInput.Id); + createdCustomer.Name.Should().BeEquivalentTo(customerInput.Name); + createdCustomer.PhoneNumber.Should().BeEquivalentTo(customerInput.PhoneNumber); + createdCustomer.Address.Should().BeEquivalentTo(customerInput.Address); + createdCustomer.Nationality.Should().BeEquivalentTo(customerInput.Nationality); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void queue_domain_events_on_create() + { + // Arrange + var customerInput = new FakeCustomer().Generate(); + + // Act + var createdCustomer = Services.Customers.Customers.Models.Customer.Create( + customerInput.Id, + customerInput.Email, + customerInput.PhoneNumber, + customerInput.Name, + customerInput.IdentityId + ); + + // Assert + createdCustomer.GetUncommittedDomainEvents().Count.Should().Be(1); + customerInput.GetUncommittedDomainEvents().FirstOrDefault().Should().BeOfType(); + } + } + + public class UpdateCustomer + { + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_update_customer_with_valid_inputs() + { + // Arrange + var fakeCustomer = new FakeCustomer().Generate(); + var fakeUpdateCustomer = new FakeCustomer(fakeCustomer.Id, fakeCustomer.IdentityId).Generate(); + + // Act + fakeCustomer.Update( + fakeUpdateCustomer.Email, + fakeUpdateCustomer.PhoneNumber, + fakeUpdateCustomer.Name, + fakeUpdateCustomer.Address, + fakeUpdateCustomer.BirthDate, + fakeUpdateCustomer.Nationality + ); + + // Assert + fakeCustomer.Nationality.Should().BeEquivalentTo(fakeUpdateCustomer.Nationality); + fakeCustomer.Email.Should().BeEquivalentTo(fakeUpdateCustomer.Email); + fakeCustomer.BirthDate.Should().BeEquivalentTo(fakeUpdateCustomer.BirthDate); + fakeCustomer.Name.Should().BeEquivalentTo(fakeUpdateCustomer.Name); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void queue_domain_event_on_update() + { + // Arrange + var fakeCustomer = new FakeCustomer().Generate(); + var fakeUpdateCustomer = new FakeCustomer(fakeCustomer.Id, fakeCustomer.IdentityId).Generate(); + + fakeCustomer.ClearDomainEvents(); + fakeUpdateCustomer.ClearDomainEvents(); + + // Act + fakeCustomer.Update( + fakeUpdateCustomer.Email, + fakeUpdateCustomer.PhoneNumber, + fakeUpdateCustomer.Name, + fakeUpdateCustomer.Address, + fakeUpdateCustomer.BirthDate, + fakeUpdateCustomer.Nationality + ); + + // Assert + fakeCustomer.GetUncommittedDomainEvents().Count.Should().Be(1); + fakeCustomer.GetUncommittedDomainEvents().FirstOrDefault().Should().BeOfType(typeof(CustomerUpdated)); + } + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerIdTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerIdTests.cs new file mode 100644 index 00000000..775005d9 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerIdTests.cs @@ -0,0 +1,42 @@ +using Bogus; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.ValueObjects; + +public class CustomerIdTests +{ + private readonly Faker _faker; + + public CustomerIdTests() + { + _faker = new Faker(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_create_with_valid_id() + { + var validId = _faker.Random.Int(1, 100); + var customerIdValueObject = CustomerId.Of(validId); + customerIdValueObject.Value.Should().Be(validId); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_throw_exception_with_invalid_id() + { + var invalidId = _faker.Random.Int(-100, 0); + + //Act + var act = () => + { + var customerIdValueObject = CustomerId.Of(invalidId); + return customerIdValueObject; + }; + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerNameTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerNameTests.cs new file mode 100644 index 00000000..2d79faf3 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/CustomerNameTests.cs @@ -0,0 +1,48 @@ +using Bogus; +using FoodDelivery.Services.Customers.Customers.Exceptions.Domain; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.Customers.ValueObjects; + +public class CustomerNameTests +{ + private readonly Faker _faker; + + public CustomerNameTests() + { + _faker = new Faker(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_create_name_with_valid_inputs() + { + var firstName = _faker.Name.FirstName(); + var lastName = _faker.Name.LastName(); + + var customerNameValueObject = CustomerName.Of(firstName, lastName); + customerNameValueObject.FirstName.Should().Be(firstName); + customerNameValueObject.LastName.Should().Be(lastName); + customerNameValueObject.FullName.Should().Be(firstName + " " + lastName); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_throw_exception_with_invalid_name() + { + string firstName = string.Empty; + string lastName = null!; + + //Act + var act = () => + { + var customerNameValueObject = CustomerName.Of(firstName, lastName); + return customerNameValueObject; + }; + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/NationalityTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/NationalityTests.cs new file mode 100644 index 00000000..34e2ee98 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/ValueObjects/NationalityTests.cs @@ -0,0 +1,3 @@ +namespace FoodDelivery.Services.Customers.UnitTests.Customers.ValueObjects; + +public class NationalityTests { } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/FoodDelivery.Services.Customers.UnitTests.csproj b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/FoodDelivery.Services.Customers.UnitTests.csproj new file mode 100644 index 00000000..2f9e5edf --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/FoodDelivery.Services.Customers.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + PreserveNewest + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs new file mode 100644 index 00000000..2e8b061d --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs @@ -0,0 +1,209 @@ +using BuildingBlocks.Core.Exception.Types; +using FoodDelivery.Services.Customers.Customers.Exceptions.Application; +using FoodDelivery.Services.Customers.Customers.ValueObjects; +using FoodDelivery.Services.Customers.Products; +using FoodDelivery.Services.Customers.Products.Models; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Exceptions; +using FoodDelivery.Services.Customers.RestockSubscriptions.ValueObjects; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; +using FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.Entities; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.RestockSubscriptions.Features.CreatingRestockSubscription.v1; + +public class CreateRestockSubscriptionTests : CustomerServiceUnitTestBase +{ + private readonly ILogger _logger; + private readonly ICatalogApiClient _catalogApiClient; + + public CreateRestockSubscriptionTests() + { + _logger = new NullLogger(); + _catalogApiClient = Substitute.For(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task can_create_restock_subscription_with_valid_inputs() + { + // Arrange + var customer = new FakeCustomer().Generate(); + await CustomersDbContext.Customers.AddAsync(customer); + await CustomersDbContext.SaveChangesAsync(); + + var fakeProductDto = new FakeProductDto().RuleFor(x => x.AvailableStock, 0).Generate(); + + var productClientDto = new GetProductByIdClientDto(fakeProductDto); + var product = Mapper.Map(productClientDto); + + //https://nsubstitute.github.io/help/return-for-args/ + //https://nsubstitute.github.io/help/set-return-value/ + //https://nsubstitute.github.io/help/argument-matchers/ + _catalogApiClient + .GetProductByIdAsync(Arg.Is(x => x == fakeProductDto!.Id), Arg.Any()) + .Returns(product); + + var command = new CreateRestockSubscription(customer.Id, fakeProductDto.Id, customer.Email); + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, _catalogApiClient, Mapper, _logger); + + // Act + var res = await handler.Handle(command, CancellationToken.None); + + // Assert + res.Should().NotBeNull(); + var entity = await CustomersDbContext.RestockSubscriptions.FindAsync( + RestockSubscriptionId.Of(res.RestockSubscriptionId) + ); + entity.Should().NotBeNull(); + entity!.Email.Value.Should().Be(command.Email); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_argument_exception_with_null_command() + { + // Arrange + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, CatalogApiClient, Mapper, _logger); + + //Act + Func act = async () => + { + await handler.Handle(null!, CancellationToken.None); + }; + + // Assert + await act.Should().ThrowAsync(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_not_found_exception_with_none_exists_customer() + { + // Arrange + var command = new CreateRestockSubscription(CustomerId.Of(1), ProductId.Of(1), "m@test.com"); + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, CatalogApiClient, Mapper, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_not_found_exception_with_none_exists_product() + { + var customer = new FakeCustomer().Generate(); + await CustomersDbContext.Customers.AddAsync(customer); + await CustomersDbContext.SaveChangesAsync(); + + // Arrange + var command = new CreateRestockSubscription(customer.Id, ProductId.Of(1), customer.Email.Value); + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, CatalogApiClient, Mapper, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should() + .ThrowAsync() + .WithMessage("*") + .Where(e => e.StatusCode == StatusCodes.Status404NotFound); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_product_has_stock_exception_with_existing_product_stock() + { + var customer = new FakeCustomer().Generate(); + await CustomersDbContext.Customers.AddAsync(customer); + await CustomersDbContext.SaveChangesAsync(CancellationToken.None); + + var fakeProductDto = new FakeProductDto().RuleFor(x => x.AvailableStock, 10).Generate(); + + var productClientDto = new GetProductByIdClientDto(fakeProductDto); + var product = Mapper.Map(productClientDto); + + //https://nsubstitute.github.io/help/return-for-args/ + //https://nsubstitute.github.io/help/set-return-value/ + //https://nsubstitute.github.io/help/argument-matchers/ + _catalogApiClient + .GetProductByIdAsync(Arg.Is(x => x == fakeProductDto!.Id), Arg.Any()) + .Returns(product); + + // Arrange + var command = new CreateRestockSubscription(customer.Id, ProductId.Of(1), customer.Email.Value); + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, _catalogApiClient, Mapper, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } + + [CategoryTrait(TestCategory.Unit)] + [Fact] + public async Task must_throw_exception_when_restock_already_exists() + { + var customer = new FakeCustomer().Generate(); + await CustomersDbContext.Customers.AddAsync(customer); + await CustomersDbContext.SaveChangesAsync(CancellationToken.None); + + var fakeProductDto = new FakeProductDto().RuleFor(x => x.AvailableStock, 0).Generate(); + var productClientDto = new GetProductByIdClientDto(fakeProductDto); + var product = Mapper.Map(productClientDto); + + //https://nsubstitute.github.io/help/return-for-args/ + //https://nsubstitute.github.io/help/set-return-value/ + //https://nsubstitute.github.io/help/argument-matchers/ + _catalogApiClient + .GetProductByIdAsync(Arg.Is(x => x == fakeProductDto!.Id), Arg.Any()) + .Returns(product); + + var fakeRestockSubscription = new FakeRestockSubscriptions() + .RuleFor(x => x.Email, customer.Email) + .RuleFor(x => x.ProductInformation, f => ProductInformation.Of(ProductId.Of(1), f.Commerce.ProductName())) + .RuleFor(x => x.Processed, false) + .Generate(); + await CustomersDbContext.RestockSubscriptions.AddAsync(fakeRestockSubscription); + await CustomersDbContext.SaveChangesAsync(CancellationToken.None); + + // Arrange + var command = new CreateRestockSubscription(customer.Id, ProductId.Of(1), customer.Email.Value); + var handler = new CreateRestockSubscriptionHandler(CustomersDbContext, _catalogApiClient, Mapper, _logger); + + //Act + Func act = async () => + { + await handler.Handle(command, CancellationToken.None); + }; + + // Assert + //https://fluentassertions.com/exceptions/ + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/RestockSubscriptionMappingTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/RestockSubscriptionMappingTests.cs new file mode 100644 index 00000000..aec82623 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/RestockSubscriptions/RestockSubscriptionMappingTests.cs @@ -0,0 +1,84 @@ +using AutoBogus; +using AutoMapper; +using FoodDelivery.Services.Customers.RestockSubscriptions.Dtos.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; +using FoodDelivery.Services.Customers.RestockSubscriptions.Models.Read; +using FoodDelivery.Services.Customers.TestShared.Fakes.RestockSubscriptions.Entities; +using FoodDelivery.Services.Customers.UnitTests.Common; +using FluentAssertions; +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Customers.UnitTests.RestockSubscriptions; + +public class RestockSubscriptionMappingTests : IClassFixture +{ + private readonly IMapper _mapper; + + public RestockSubscriptionMappingTests(MappingFixture fixture) + { + _mapper = fixture.Mapper; + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void must_success_with_valid_configuration() + { + _mapper.ConfigurationProvider.AssertConfigurationIsValid(); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_restock_subscription_to_restock_subscription_dto() + { + var restockSubscription = new FakeRestockSubscriptions().Generate(); + var dto = _mapper.Map(restockSubscription); + restockSubscription.CustomerId.Value.Should().Be(dto.CustomerId); + restockSubscription.Email.Value.Should().Be(dto.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_restock_subscription_to_restock_subscription_read() + { + var restockSubscription = new FakeRestockSubscriptions().Generate(); + var readModel = _mapper.Map(restockSubscription); + restockSubscription.CustomerId.Value.Should().Be(readModel.CustomerId); + restockSubscription.Email.Value.Should().Be(readModel.Email); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_restock_subscription_read_to_restock_subscription_dto() + { + var restockSubscriptionRead = AutoFaker.Generate(); + var dto = _mapper.Map(restockSubscriptionRead); + restockSubscriptionRead.RestockSubscriptionId.Should().Be(dto.Id); + restockSubscriptionRead.CustomerId.Should().Be(dto.CustomerId); + } + + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void can_map_create_restock_subscription_read_to_restock_subscription_read() + { + var createMongoRestockSubscriptionRead = AutoFaker.Generate(); + var readModel = _mapper.Map(createMongoRestockSubscriptionRead); + + createMongoRestockSubscriptionRead.RestockSubscriptionId.Should().Be(readModel.RestockSubscriptionId); + createMongoRestockSubscriptionRead.CustomerId.Should().Be(readModel.CustomerId); + } + + // [Fact] + // [CategoryTrait(TestCategory.Unit)] + // public void can_map_update_restock_subscription_read_to_restock_subscription_read() + // { + // var updateMongoRestockSubscriptionRead = new UpdateMongoRestockSubscriptionReadModel( + // new FakeRestockSubscriptions().Generate(), + // false + // ); + // var readModel = _mapper.Map(updateMongoRestockSubscriptionRead); + // + // updateMongoRestockSubscriptionRead.RestockSubscription.Id.Value.Should().Be(readModel.RestockSubscriptionId); + // updateMongoRestockSubscriptionRead.RestockSubscription.CustomerId.Value.Should().Be(readModel.CustomerId); + // } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs new file mode 100644 index 00000000..1fa7d3d1 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs @@ -0,0 +1,12 @@ +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; +using FoodDelivery.Services.Customers.TestShared.Fixtures; + +namespace FoodDelivery.Services.Customers.UnitTests; + +// https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x +// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions +[CollectionDefinition(Name)] +public class UnitTestCollection : ICollectionFixture +{ + public const string Name = "UnitTest Test"; +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/XunitMetadata.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/appsettings.json b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/appsettings.json new file mode 100644 index 00000000..5a501396 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/appsettings.json @@ -0,0 +1,10 @@ +{ + "IdentityApiClientOptions": { + "BaseApiAddress": "http://localhost:7000", + "UsersEndpoint": "api/v1/identity/users" + }, + "CatalogsApiClientOptions": { + "BaseApiAddress": "http://localhost:4000", + "ProductsEndpoint": "api/v1/catalogs/products" + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/xunit.runner.json b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/FoodDelivery.Services.Identity.EndToEndTests.csproj b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/FoodDelivery.Services.Identity.EndToEndTests.csproj new file mode 100644 index 00000000..320450f6 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/FoodDelivery.Services.Identity.EndToEndTests.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/UnitTest1.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/UnitTest1.cs new file mode 100644 index 00000000..93491e4d --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/UnitTest1.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace FoodDelivery.Services.Identity.EndToEndTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() { } +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/XunitMetadata.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/xunit.runner.json b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/xunit.runner.json new file mode 100644 index 00000000..930521b9 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.EndToEndTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": true, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/FoodDelivery.Services.Identity.IntegrationTests.csproj b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/FoodDelivery.Services.Identity.IntegrationTests.csproj new file mode 100644 index 00000000..520bcada --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/FoodDelivery.Services.Identity.IntegrationTests.csproj @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/Users/Features/RegisteringUser/v1/RegisterUserTests.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/Users/Features/RegisteringUser/v1/RegisterUserTests.cs new file mode 100644 index 00000000..3fad6fb5 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/Users/Features/RegisteringUser/v1/RegisterUserTests.cs @@ -0,0 +1,64 @@ +using Bogus; +using FoodDelivery.Services.Identity.Users.Features.GettingUserById.v1; +using FoodDelivery.Services.Identity.Users.Features.RegisteringUser.v1; +using FoodDelivery.Services.Shared.Identity.Users.Events.v1.Integration; +using Microsoft.Extensions.DependencyInjection; +using Tests.Shared.Fixtures; +using Xunit.Abstractions; + +namespace FoodDelivery.Services.Identity.IntegrationTests.Users.Features.RegisteringUser.v1; + +// public class RegisterUserTests : IntegrationTestBase +// { +// private static RegisterUser _registerUser; +// +// public RegisterUserTests(IntegrationTestFixture integrationTestFixture, +// ITestOutputHelper outputHelper) : base(integrationTestFixture, outputHelper) +// { +// // Arrange +// _registerUser = new Faker().CustomInstantiator(faker => +// new RegisterUser( +// faker.Person.FirstName, +// faker.Person.LastName, +// faker.Person.UserName, +// faker.Person.Email, +// "123456", +// "123456")) +// .Generate(); +// } +// +// protected override void RegisterTestsServices(IServiceCollection services) +// { +// base.RegisterTestsServices(services); +// // services.ReplaceScoped(); +// } +// +// [Fact] +// public async Task register_new_user_command_should_persist_new_user_in_db() +// { +// // Act +// var result = await IntegrationTestFixture.SendAsync(_registerUser, CancellationToken); +// +// // Assert +// result.UserIdentity.Should().NotBeNull(); +// +// // var user = await IdentityModule.FindWriteAsync(result.UserIdentity.InternalCommandId); +// // user.Should().NotBeNull(); +// +// var userByIdResponse = +// await IntegrationTestFixture.QueryAsync(new GetUserById(result.UserIdentity.Id)); +// +// userByIdResponse.IdentityUser.Should().NotBeNull(); +// userByIdResponse.IdentityUser.Id.Should().Be(result.UserIdentity.Id); +// } +// +// [Fact] +// public async Task register_new_user_command_should_publish_message_to_broker() +// { +// // Act +// await IntegrationTestFixture.SendAsync(_registerUser, CancellationToken); +// +// // Assert +// await IntegrationTestFixture.WaitForPublishing(); +// } +// } diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/XunitMetadata.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/xunit.runner.json b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..adcf8123 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.IntegrationTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/FoodDelivery.Services.Identity.TestShared.csproj b/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/FoodDelivery.Services.Identity.TestShared.csproj new file mode 100644 index 00000000..db4077a1 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/FoodDelivery.Services.Identity.TestShared.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/UnitTest1.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/UnitTest1.cs new file mode 100644 index 00000000..0de00da5 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.TestShared/UnitTest1.cs @@ -0,0 +1,7 @@ +namespace FoodDelivery.Services.Identity.TestShared; + +public class UnitTest1 +{ + [Fact] + public void Test1() { } +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/FoodDelivery.Services.Identity.UnitTests.csproj b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/FoodDelivery.Services.Identity.UnitTests.csproj new file mode 100644 index 00000000..66a8e2c6 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/FoodDelivery.Services.Identity.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/Tests.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/Tests.cs new file mode 100644 index 00000000..9cf583f5 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/Tests.cs @@ -0,0 +1,10 @@ +using Tests.Shared.XunitCategories; + +namespace FoodDelivery.Services.Identity.UnitTests; + +public class Tests +{ + [Fact] + [CategoryTrait(TestCategory.Unit)] + public void Test1() { } +} diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/XunitMetadata.cs b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/XunitMetadata.cs new file mode 100644 index 00000000..c868ccdd --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/XunitMetadata.cs @@ -0,0 +1,6 @@ +using Tests.Shared.XunitFramework; + +[assembly: TestFramework( + $"{nameof(Tests)}.{nameof(Tests.Shared)}.{nameof(Tests.Shared.XunitFramework)}.{nameof(CustomTestFramework)}", + $"{nameof(Tests)}.{nameof(Tests.Shared)}" +)] diff --git a/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/xunit.runner.json b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/xunit.runner.json new file mode 100644 index 00000000..930521b9 --- /dev/null +++ b/tests/Services/Identity/FoodDelivery.Services.Identity.UnitTests/xunit.runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": true, + "parallelizeTestCollections": false, + "methodDisplay": "method", + "methodDisplayOptions": "all", + "diagnosticMessages" : true +} diff --git a/tests/Shared/Tests.Shared/Extensions/HttpClientExtensions.cs b/tests/Shared/Tests.Shared/Extensions/HttpClientExtensions.cs index 7e9e0cee..abac9168 100644 --- a/tests/Shared/Tests.Shared/Extensions/HttpClientExtensions.cs +++ b/tests/Shared/Tests.Shared/Extensions/HttpClientExtensions.cs @@ -1,5 +1,9 @@ using System.Dynamic; +using System.IdentityModel.Tokens.Jwt; using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using WebMotions.Fake.Authentication.JwtBearer; namespace Tests.Shared.Extensions; @@ -14,4 +18,19 @@ public static HttpClient AddAuthClaims(this HttpClient client, params string[] r return client; } + + /// + /// Set a fake bearer token in form of a JWT form the list of claims. + /// + /// + /// + /// + public static HttpClient SetFakeJwtBearerClaims(this HttpClient client, IEnumerable claims) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var securityToken = new JwtSecurityToken(claims: claims, expires: DateTime.UtcNow.AddDays(7)); + + var jwt = tokenHandler.WriteToken(securityToken); + return client.SetToken(FakeJwtBearerDefaults.AuthenticationScheme, jwt); + } } diff --git a/tests/Shared/Tests.Shared/Extensions/HttpResponseMessageExtensions.cs b/tests/Shared/Tests.Shared/Extensions/HttpResponseMessageExtensions.cs new file mode 100644 index 00000000..298a6c5d --- /dev/null +++ b/tests/Shared/Tests.Shared/Extensions/HttpResponseMessageExtensions.cs @@ -0,0 +1,116 @@ +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using FluentAssertions.Primitives; + +namespace Tests.Shared.Extensions; + +public static class HttpResponseMessageExtensions +{ + /// + /// Check for exact expected problem detail and title in the response + /// + /// + /// + /// + public static AndConstraint HasProblemDetail( + this HttpResponseMessageAssertions assertions, + object expectedProblem + ) + { + var responseProblemDetails = assertions.Subject.Content + .ReadFromJsonAsync() + .GetAwaiter() + .GetResult(); + responseProblemDetails.Should().BeEquivalentTo(expectedProblem); + var responseMessageAssertions = new FluentAssertions.Web.HttpResponseMessageAssertions(assertions.Subject); + + return new AndConstraint(responseMessageAssertions); + } + + /// + /// Check for containing expected problem detail and title in the response + /// + /// + /// + /// + /// + public static AndConstraint ContainsProblemDetail( + this HttpResponseMessageAssertions assertions, + string detail, + string? title = null + ) + { + var responseProblemDetails = assertions.Subject.Content + .ReadFromJsonAsync() + .GetAwaiter() + .GetResult(); + responseProblemDetails.Should().NotBeNull(); + responseProblemDetails!.Detail.Should().Contain(detail); + + if (!string.IsNullOrWhiteSpace(title)) + responseProblemDetails.Title.Should().Be(title); + + var responseMessageAssertions = new FluentAssertions.Web.HttpResponseMessageAssertions(assertions.Subject); + + return new AndConstraint(responseMessageAssertions); + } + + /// + /// Check for containing expected problem detail in the response + /// + /// + /// + /// + public static AndConstraint ContainsProblemDetail( + this HttpResponseMessageAssertions assertions, + ProblemDetails expectedProblemDetails + ) + { + var responseProblemDetails = assertions.Subject.Content + .ReadFromJsonAsync() + .GetAwaiter() + .GetResult(); + responseProblemDetails.Should().NotBeNull(); + + if (!string.IsNullOrWhiteSpace(expectedProblemDetails.Title)) + responseProblemDetails!.Title.Should().Be(expectedProblemDetails.Title); + + if (!string.IsNullOrWhiteSpace(expectedProblemDetails.Detail)) + responseProblemDetails!.Detail.Should().Contain(expectedProblemDetails.Detail); + + if (!string.IsNullOrWhiteSpace(expectedProblemDetails.Type)) + responseProblemDetails!.Type.Should().Be(expectedProblemDetails.Type); + + if (expectedProblemDetails.Status is not null) + responseProblemDetails!.Status.Should().Be(expectedProblemDetails.Status); + + var responseMessageAssertions = new FluentAssertions.Web.HttpResponseMessageAssertions(assertions.Subject); + + return new AndConstraint(responseMessageAssertions); + } + + public static AndConstraint HasResponse( + this HttpResponseMessageAssertions assertions, + object? expectedObject = null, + Action? responseAction = null + ) + { + assertions.BeSuccessful(); + + var responseObject = assertions.Subject.Content.ReadFromJsonAsync().GetAwaiter().GetResult(); + + responseObject.Should().NotBeNull(); + + if (expectedObject is not null) + { + //https://fluentassertions.com/objectgraphs/ + responseObject.Should().BeEquivalentTo(expectedObject, options => options.ExcludingMissingMembers()); + } + + responseAction?.Invoke(responseObject); + + var responseMessageAssertions = new FluentAssertions.Web.HttpResponseMessageAssertions(assertions.Subject); + return new AndConstraint(responseMessageAssertions); + } +} diff --git a/tests/Shared/Tests.Shared/Factory/CustomWebApplicationFactory.cs b/tests/Shared/Tests.Shared/Factory/CustomWebApplicationFactory.cs index fa2d7c14..dff03064 100644 --- a/tests/Shared/Tests.Shared/Factory/CustomWebApplicationFactory.cs +++ b/tests/Shared/Tests.Shared/Factory/CustomWebApplicationFactory.cs @@ -1,5 +1,6 @@ -using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; +using BuildingBlocks.Security.Extensions; +using BuildingBlocks.Security.Jwt; +using BuildingBlocks.Web.Extensions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -15,8 +16,6 @@ using Serilog.Events; using Tests.Shared.Auth; using WebMotions.Fake.Authentication.JwtBearer; -using Xunit; -using Xunit.Abstractions; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Tests.Shared.Factory; @@ -30,9 +29,12 @@ public class CustomWebApplicationFactory : WebApplicationFactory? _customWebHostBuilder; private Action? _customHostBuilder; private Action? _configureAppConfigurations; + private Action? _testServices; + private readonly Dictionary _inMemoryConfigs = new(); + public Action? ConfigurationAction { get; set; } public Action? TestConfigureServices { get; set; } - public Action? TestConfigureApp { get; set; } + public Action? TestConfigureApp { get; set; } public ILogger Logger => Services.GetRequiredService>>(); @@ -40,6 +42,13 @@ public class CustomWebApplicationFactory : WebApplicationFactory _outputHelper = value; + public CustomWebApplicationFactory WithTestServices(Action services) + { + _testServices += services; + + return this; + } + public CustomWebApplicationFactory WithConfigureAppConfigurations( Action builder ) @@ -75,13 +84,14 @@ public CustomWebApplicationFactory WithHostBuilder(Action { //https://github.com/trbenning/serilog-sinks-xunit - if (_outputHelper is { }) + if (_outputHelper is not null) { loggerConfiguration.WriteTo.TestOutput( _outputHelper, @@ -92,54 +102,6 @@ protected override IHost CreateHost(IHostBuilder builder) } ); - builder.ConfigureServices(services => - { - services.AddScoped(_ => new StringWriter()); - services.AddScoped( - sp => new StringReader(sp.GetRequiredService().ToString() ?? "") - ); - - // services.RemoveAll(typeof(IHostedService)); - - // TODO: Web could use this in E2E test for running another service during our test - // https://milestone.topics.it/2021/11/10/http-client-factory-in-integration-testing.html - // services.Replace(new ServiceDescriptor(typeof(IHttpClientFactory), - // new DelegateHttpClientFactory(ClientProvider))); - - //// https://blog.joaograssi.com/posts/2021/asp-net-core-testing-permission-protected-api-endpoints/ - //// This helper just supports jwt Scheme, and for Identity server Scheme will crash so we should disable AddIdentityServer() - // services.AddScoped(_ => CreateAnonymouslyUserMock()); - // services.ReplaceSingleton(CreateCustomTestHttpContextAccessorMock); - // services.AddTestAuthentication(); - - // Or - // add authentication using a fake jwt bearer - we can use SetAdminUser method to set authenticate user to existing HttContextAccessor - // https://github.com/webmotions/fake-authentication-jwtbearer - // https://github.com/webmotions/fake-authentication-jwtbearer/issues/14 - services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = FakeJwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = FakeJwtBearerDefaults.AuthenticationScheme; - }) - .AddFakeJwtBearer(); - }); - - builder.ConfigureWebHost(wb => - { - wb.ConfigureTestServices(services => - { - TestConfigureServices?.Invoke(services); - }); - - // //https://github.com/dotnet/aspnetcore/issues/45372 - // wb.Configure(x => - // { - // }); - - _customWebHostBuilder?.Invoke(wb); - }); - builder.UseDefaultServiceProvider( (env, c) => { @@ -170,9 +132,11 @@ protected override IHost CreateHost(IHostBuilder builder) //// add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration //// https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ - // configurationBuilder.AddInMemoryCollection(new Dictionary {}); + configurationBuilder.AddInMemoryCollection(_inMemoryConfigs); + ConfigurationAction?.Invoke(hostingContext.Configuration); _configureAppConfigurations?.Invoke(hostingContext, configurationBuilder); + TestConfigureApp?.Invoke(hostingContext, configurationBuilder); } ); @@ -181,6 +145,107 @@ protected override IHost CreateHost(IHostBuilder builder) return base.CreateHost(builder); } + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // test services will call after registering all application services in program.cs and can override them with `Replace` or `Remove` dependencies + builder.ConfigureTestServices(services => + { + //// https://andrewlock.net/converting-integration-tests-to-net-core-3/ + //// Don't run IHostedServices when running as a test + // services.RemoveAll(typeof(IHostedService)); + + // TODO: Web could use this in E2E test for running another service during our test + // https://milestone.topics.it/2021/11/10/http-client-factory-in-integration-testing.html + // services.Replace(new ServiceDescriptor(typeof(IHttpClientFactory), + // new DelegateHttpClientFactory(ClientProvider))); + + //// https://blog.joaograssi.com/posts/2021/asp-net-core-testing-permission-protected-api-endpoints/ + //// This helper just supports jwt Scheme, and for Identity server Scheme will crash so we should disable AddIdentityServer() + // services.TryAddScoped(_ => CreateAnonymouslyUserMock()); + // services.ReplaceSingleton(CreateCustomTestHttpContextAccessorMock); + // services.AddTestAuthentication(); + + // Or + // add authentication using a fake jwt bearer - we can use SetAdminUser method to set authenticate user to existing HttContextAccessor + // https://github.com/webmotions/fake-authentication-jwtbearer + // https://github.com/webmotions/fake-authentication-jwtbearer/issues/14 + services + // will skip registering dependencies if exists previously, but will override authentication option inner configure delegate through Configure + .AddAuthentication(options => + { + // choosing `FakeBearer` scheme (instead of exiting default scheme of application) as default in runtime for authentication and authorization middleware + options.DefaultAuthenticateScheme = FakeJwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = FakeJwtBearerDefaults.AuthenticationScheme; + }) + .AddFakeJwtBearer(c => + { + // for working fake token this should be set to jwt + c.BearerValueType = FakeJwtBearerBearerValueType.Jwt; + }) + .Services.AddCustomAuthorization( + rolePolicies: new List + { + new(Constants.Users.Admin.Role, new List { Constants.Users.Admin.Role }), + new(Constants.Users.NormalUser.Role, new List { Constants.Users.NormalUser.Role }), + }, + scheme: FakeJwtBearerDefaults.AuthenticationScheme + ); + + _testServices?.Invoke(services); + TestConfigureServices?.Invoke(services); + }); + + // //https://github.com/dotnet/aspnetcore/issues/45372 + // wb.Configure(x => + // { + // }); + + _customWebHostBuilder?.Invoke(builder); + + base.ConfigureWebHost(builder); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + } + + public void AddOverrideInMemoryConfig(string key, string value) + { + // overriding app configs with using in-memory configs + // add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration + // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ + _inMemoryConfigs.Add(key, value); + } + + public void AddOverrideInMemoryConfig(IDictionary inMemConfigs) + { + // overriding app configs with using in-memory configs + // add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration + // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ + inMemConfigs.ToList().ForEach(x => _inMemoryConfigs.Add(x.Key, x.Value)); + } + + public void AddOverrideEnvKeyValue(string key, string value) + { + // overriding app configs with using environments + Environment.SetEnvironmentVariable(key, value); + } + + public void AddOverrideEnvKeyValues(IDictionary keyValues) + { + foreach (var (key, value) in keyValues) + { + // overriding app configs with using environments + Environment.SetEnvironmentVariable(key, value); + } + } + private static IHttpContextAccessor CreateCustomTestHttpContextAccessorMock(IServiceProvider serviceProvider) { var httpContextAccessorMock = Substitute.For(); @@ -196,19 +261,4 @@ private static IHttpContextAccessor CreateCustomTestHttpContextAccessorMock(ISer httpContextAccessorMock.HttpContext.User = res.Ticket?.Principal!; return httpContextAccessorMock; } - - private MockAuthUser CreateAnonymouslyUserMock() - { - return new MockAuthUser(); - } - - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - public new async Task DisposeAsync() - { - await base.DisposeAsync(); - } } diff --git a/tests/Shared/Tests.Shared/Fixtures/EfDbContextTransactionFixture.cs b/tests/Shared/Tests.Shared/Fixtures/EfDbContextTransactionFixture.cs index 89274ee2..ca3afaf8 100644 --- a/tests/Shared/Tests.Shared/Fixtures/EfDbContextTransactionFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/EfDbContextTransactionFixture.cs @@ -1,13 +1,9 @@ -using Ardalis.GuardClauses; using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Reflection.Extensions; -using BuildingBlocks.Core.Types.Extensions; using BuildingBlocks.Persistence.EfCore.Postgres; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Testcontainers.PostgreSql; using Tests.Shared.Helpers; using Xunit.Sdk; @@ -21,28 +17,24 @@ public class EfDbContextTransactionFixture : IAsyncLifetime private readonly string? _migrationAssembly; private bool _isInnerTransaction; private IDbContextTransaction _transaction = null!; + public PostgresContainerOptions PostgresContainerOptions { get; } public TContext DbContext { get; private set; } = default!; - public PostgreSqlTestcontainer Container { get; } + public PostgreSqlContainer Container { get; } + public int HostPort => Container.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort); + public int TcpContainerPort => PostgreSqlBuilder.PostgreSqlPort; public EfDbContextTransactionFixture(IMessageSink messageSink) { _messageSink = messageSink; _migrationAssembly = typeof(TContext).Assembly.GetName().Name; - var postgresOptions = ConfigurationHelper.BindOptions(); - Guard.Against.Null(postgresOptions); + PostgresContainerOptions = ConfigurationHelper.BindOptions(); + PostgresContainerOptions.NotBeNull(); - var postgresContainerBuilder = new TestcontainersBuilder() - .WithDatabase( - new PostgreSqlTestcontainerConfiguration - { - Database = postgresOptions.DatabaseName, - Username = postgresOptions.UserName, - Password = postgresOptions.Password, - } - ) + var postgresContainerBuilder = new PostgreSqlBuilder() + .WithDatabase(PostgresContainerOptions.DatabaseName) .WithCleanUp(true) - .WithName(postgresOptions.Name) - .WithImage(postgresOptions.ImageName); + .WithName(PostgresContainerOptions.Name) + .WithImage(PostgresContainerOptions.ImageName); Container = postgresContainerBuilder.Build(); } @@ -70,7 +62,7 @@ public async Task InitializeAsync() { await Container.StartAsync(); var options = ConfigurationHelper.BindOptions(); - options.ConnectionString = Container.ConnectionString; + options.ConnectionString = Container.GetConnectionString(); options.MigrationAssembly = _migrationAssembly; var optionBuilder = new DbContextOptionsBuilder() @@ -107,13 +99,4 @@ await strategy.ExecuteAsync(async () => _transaction = await DbContext.Database.BeginTransactionAsync(); }); } - - private sealed class PostgresContainerOptions - { - public string Name { get; set; } = "postgres_" + Guid.NewGuid(); - public string ImageName { get; set; } = "postgres:latest"; - public string DatabaseName { get; set; } = "test_db"; - public string UserName { get; set; } = "postgres"; - public string Password { get; set; } = "postgres"; - } } diff --git a/tests/Shared/Tests.Shared/Fixtures/MongoContainerFixture.cs b/tests/Shared/Tests.Shared/Fixtures/MongoContainerFixture.cs index 2fdc66f5..5217ee5f 100644 --- a/tests/Shared/Tests.Shared/Fixtures/MongoContainerFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/MongoContainerFixture.cs @@ -1,9 +1,7 @@ -using Ardalis.GuardClauses; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; +using BuildingBlocks.Core.Extensions; using Tests.Shared.Helpers; using MongoDB.Driver; +using Testcontainers.MongoDb; using Xunit.Sdk; namespace Tests.Shared.Fixtures; @@ -11,31 +9,25 @@ namespace Tests.Shared.Fixtures; public class MongoContainerFixture : IAsyncLifetime { private readonly IMessageSink _messageSink; - private readonly MongoContainerOptions _mongoContainerOptions; - public MongoDbTestcontainer Container { get; } + + public MongoContainerOptions MongoContainerOptions { get; } + public MongoDbContainer Container { get; } + public int HostPort => Container.GetMappedPublicPort(MongoDbBuilder.MongoDbPort); + public int TcpContainerPort => MongoDbBuilder.MongoDbPort; public MongoContainerFixture(IMessageSink messageSink) { _messageSink = messageSink; var mongoContainerOptions = ConfigurationHelper.BindOptions(); - Guard.Against.Null(mongoContainerOptions); - _mongoContainerOptions = mongoContainerOptions; + mongoContainerOptions.NotBeNull(); + MongoContainerOptions = mongoContainerOptions; - var postgresContainerBuilder = new TestcontainersBuilder() - .WithDatabase( - new MongoDbTestcontainerConfiguration - { - Database = mongoContainerOptions.DatabaseName, - Username = mongoContainerOptions.UserName, - Password = mongoContainerOptions.Password, - } - ) + var postgresContainerBuilder = new MongoDbBuilder() .WithName(mongoContainerOptions.Name) .WithCleanUp(true) - // https://github.com/testcontainers/testcontainers-dotnet/issues/734 - // testcontainers has a problem with using mongo:latest version for now we use testcontainer default image - //.WithImage(mongoContainerOptions.ImageName) - ; + // https://github.com/testcontainers/testcontainers-dotnet/issues/734 + // testcontainers has a problem with using mongo:latest version for now we use testcontainer default image + .WithImage(mongoContainerOptions.ImageName); Container = postgresContainerBuilder.Build(); } @@ -48,7 +40,11 @@ public async Task ResetDbAsync(CancellationToken cancellationToken = default) public async Task InitializeAsync() { await Container.StartAsync(); - _messageSink.OnMessage(new DiagnosticMessage($"Mongo fixture started on Host port {Container.Port}...")); + _messageSink.OnMessage( + new DiagnosticMessage( + $"Mongo fixture started on Host port {HostPort} and container tcp port {TcpContainerPort}..." + ) + ); } public async Task DisposeAsync() @@ -61,29 +57,27 @@ public async Task DisposeAsync() private async Task DropDatabaseCollections(CancellationToken cancellationToken) { //https://stackoverflow.com/questions/3366397/delete-everything-in-a-mongodb-database - MongoClient dbClient = new MongoClient(Container.ConnectionString); + MongoClient dbClient = new MongoClient(Container.GetConnectionString()); //// Drop database completely in each run or drop only the collections in exisitng database //await dbClient.DropDatabaseAsync(Container.Database, cancellationToken); var collections = await dbClient - .GetDatabase(Container.Database) + .GetDatabase(MongoContainerOptions.DatabaseName) .ListCollectionsAsync(cancellationToken: cancellationToken); foreach (var collection in collections.ToList()) { await dbClient - .GetDatabase(Container.Database) + .GetDatabase(MongoContainerOptions.DatabaseName) .DropCollectionAsync(collection["name"].AsString, cancellationToken); } } +} - private sealed class MongoContainerOptions - { - public string Name { get; set; } = "mongo_" + Guid.NewGuid(); - public string ImageName { get; set; } = "mongo:latest"; - public string DatabaseName { get; set; } = "test_db"; - public string UserName { get; set; } = "admin"; - public string Password { get; set; } = "admin"; - } +public sealed class MongoContainerOptions +{ + public string Name { get; set; } = "mongo_" + Guid.NewGuid(); + public string ImageName { get; set; } = "mongo:latest"; + public string DatabaseName { get; set; } = "test_db"; } diff --git a/tests/Shared/Tests.Shared/Fixtures/PostgresContainerFixture.cs b/tests/Shared/Tests.Shared/Fixtures/PostgresContainerFixture.cs index 0d05b085..18884163 100644 --- a/tests/Shared/Tests.Shared/Fixtures/PostgresContainerFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/PostgresContainerFixture.cs @@ -1,10 +1,8 @@ -using Ardalis.GuardClauses; +using BuildingBlocks.Core.Extensions; using Dapper; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; using Npgsql; using Respawn; +using Testcontainers.PostgreSql; using Tests.Shared.Helpers; using Xunit.Sdk; @@ -13,28 +11,22 @@ namespace Tests.Shared.Fixtures; public class PostgresContainerFixture : IAsyncLifetime { private readonly IMessageSink _messageSink; - private readonly PostgresContainerOptions _postgresContainerOptions; - public PostgreSqlTestcontainer Container { get; } + public PostgresContainerOptions PostgresContainerOptions { get; } + public PostgreSqlContainer Container { get; } + public int HostPort => Container.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort); + public int TcpContainerPort => PostgreSqlBuilder.PostgreSqlPort; public PostgresContainerFixture(IMessageSink messageSink) { _messageSink = messageSink; - var postgresOptions = ConfigurationHelper.BindOptions(); - Guard.Against.Null(postgresOptions); - _postgresContainerOptions = postgresOptions; + PostgresContainerOptions = ConfigurationHelper.BindOptions(); + PostgresContainerOptions.NotBeNull(); - var postgresContainerBuilder = new TestcontainersBuilder() - .WithDatabase( - new PostgreSqlTestcontainerConfiguration - { - Database = postgresOptions.DatabaseName, - Username = postgresOptions.UserName, - Password = postgresOptions.Password, - } - ) + var postgresContainerBuilder = new PostgreSqlBuilder() + .WithDatabase(PostgresContainerOptions.DatabaseName) .WithCleanUp(true) - .WithName(postgresOptions.Name) - .WithImage(postgresOptions.ImageName); + .WithName(PostgresContainerOptions.Name) + .WithImage(PostgresContainerOptions.ImageName); Container = postgresContainerBuilder.Build(); } @@ -42,27 +34,35 @@ public PostgresContainerFixture(IMessageSink messageSink) public async Task InitializeAsync() { await Container.StartAsync(); - _messageSink.OnMessage(new DiagnosticMessage($"Postgres fixture started on Host port {Container.Port}...")); + _messageSink.OnMessage( + new DiagnosticMessage( + $"Postgres fixture started on Host port {HostPort} and container tcp port {TcpContainerPort}..." + ) + ); } public async Task ResetDbAsync(CancellationToken cancellationToken = default) { try { - await using var connection = new NpgsqlConnection(Container.ConnectionString); + await using var connection = new NpgsqlConnection(Container.GetConnectionString()); await connection.OpenAsync(cancellationToken); - + // after new nugget version respawn than 6 according this https://github.com/jbogard/Respawn/pull/115 pull request we don't need this check and should remove await CheckForExistingDatabase(connection); var checkpoint = await Respawner.CreateAsync( connection, new RespawnerOptions { DbAdapter = DbAdapter.Postgres } ); + //TODO: should update to latest version after release a new version + // https://github.com/jbogard/Respawn/issues/108 + // https://github.com/jbogard/Respawn/pull/115 - fixed + // waiting for new nuget version of respawn, current is 6. await checkpoint.ResetAsync(connection)!; } catch (Exception e) { - throw new Exception(e.Message); + _messageSink.OnMessage(new DiagnosticMessage(e.Message)); } } @@ -77,13 +77,13 @@ private async Task CheckForExistingDatabase(NpgsqlConnection connection) { var existsDb = await connection.ExecuteScalarAsync( "SELECT 1 FROM pg_catalog.pg_database WHERE datname= @dbname", - param: new { dbname = Container.Database } + param: new { dbname = PostgresContainerOptions.DatabaseName } ); if (existsDb == false) { await connection.ExecuteAsync( "CREATE DATABASE @DBName", - param: new { DBName = _postgresContainerOptions.DatabaseName } + param: new { DBName = PostgresContainerOptions.DatabaseName } ); } @@ -97,13 +97,11 @@ await connection.ExecuteAsync( // "create table \"foo\" (value int)"); // } } +} - private sealed class PostgresContainerOptions - { - public string Name { get; set; } = "postgres_" + Guid.NewGuid(); - public string ImageName { get; set; } = "postgres:latest"; - public string DatabaseName { get; set; } = "test_db"; - public string UserName { get; set; } = "postgres"; - public string Password { get; set; } = "postgres"; - } +public sealed class PostgresContainerOptions +{ + public string Name { get; set; } = "postgres_" + Guid.NewGuid(); + public string ImageName { get; set; } = "postgres:latest"; + public string DatabaseName { get; set; } = "test_db"; } diff --git a/tests/Shared/Tests.Shared/Fixtures/RabbitMQContainerFixture.cs b/tests/Shared/Tests.Shared/Fixtures/RabbitMQContainerFixture.cs index f8563057..1cffa420 100644 --- a/tests/Shared/Tests.Shared/Fixtures/RabbitMQContainerFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/RabbitMQContainerFixture.cs @@ -1,8 +1,6 @@ -using Ardalis.GuardClauses; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; +using BuildingBlocks.Core.Extensions; using EasyNetQ.Management.Client; +using Testcontainers.RabbitMq; using Tests.Shared.Helpers; using Xunit.Sdk; @@ -16,32 +14,28 @@ namespace Tests.Shared.Fixtures; public class RabbitMQContainerFixture : IAsyncLifetime { private readonly IMessageSink _messageSink; - private readonly RabbitMQContainerOptions _rabbitMqContainerOptions; - public RabbitMqTestcontainer Container { get; } + public RabbitMqContainer Container { get; } public int ApiPort => Container.GetMappedPublicPort(15672); + public int HostPort => Container.GetMappedPublicPort(RabbitMqBuilder.RabbitMqPort); + public int TcpContainerPort => RabbitMqBuilder.RabbitMqPort; + public RabbitMQContainerOptions RabbitMqContainerOptions { get; } public RabbitMQContainerFixture(IMessageSink messageSink) { _messageSink = messageSink; - var rabbitmqContainerOptions = ConfigurationHelper.BindOptions(); - Guard.Against.Null(rabbitmqContainerOptions); - _rabbitMqContainerOptions = rabbitmqContainerOptions; - - var rabbitmqContainerBuilder = new TestcontainersBuilder() - .WithMessageBroker( - new RabbitMqTestcontainerConfiguration - { - Username = rabbitmqContainerOptions.UserName, - Password = rabbitmqContainerOptions.Password, - } - ) + RabbitMqContainerOptions = ConfigurationHelper.BindOptions(); + RabbitMqContainerOptions.NotBeNull(); + + var rabbitmqContainerBuilder = new RabbitMqBuilder() + .WithUsername(RabbitMqContainerOptions.UserName) + .WithPassword(RabbitMqContainerOptions.Password) // set custom host http port for container http port 15672, beside of automatic tcp port will assign for container port 5672 (default port) .WithPortBinding(15672, true) // we could comment this line, this is default port for testcontainer .WithPortBinding(5672, true) .WithCleanUp(true) - .WithName(rabbitmqContainerOptions.Name) - .WithImage(rabbitmqContainerOptions.ImageName); + .WithName(RabbitMqContainerOptions.Name) + .WithImage(RabbitMqContainerOptions.ImageName); Container = rabbitmqContainerBuilder.Build(); } @@ -55,13 +49,12 @@ public async Task CleanupQueuesAsync(CancellationToken cancellationToken = defau // https://www.planetgeek.ch/2015/08/16/cleaning-up-queues-and-exchanges-on-rabbitmq/ // https://www.planetgeek.ch/2015/08/31/cleanup-code-for-cleaning-up-queues-and-exchanges-on-rabbitmq/ - // here I used rabbitmq http apis (Management Plugin) but also we can also use RabbitMQ client library and channel.ExchangeDelete(), channel.QueueDelete(), official client // is not complete for administrative works for example it doesn't have GetAllQueues, GetAllExchanges var managementClient = new ManagementClient( $"http://{Container.Hostname}", - Container.Username, - Container.Password, + RabbitMqContainerOptions.UserName, + RabbitMqContainerOptions.Password, ApiPort ); @@ -82,8 +75,8 @@ public async Task DropElementsAsync(CancellationToken cancellationToken = defaul // is not complete for administrative works for example it doesn't have GetAllQueues, GetAllExchanges var managementClient = new ManagementClient( $"http://{Container.Hostname}", - Container.Username, - Container.Password, + RabbitMqContainerOptions.UserName, + RabbitMqContainerOptions.Password, apiPort ); @@ -116,7 +109,9 @@ public async Task InitializeAsync() { await Container.StartAsync(); _messageSink.OnMessage( - new DiagnosticMessage($"RabbitMq fixture started on Host port {Container.Port} and Api Port {ApiPort}...") + new DiagnosticMessage( + $"RabbitMq fixture started on api port {ApiPort}, container tcp port {TcpContainerPort} and host port: {HostPort}..." + ) ); } @@ -126,13 +121,13 @@ public async Task DisposeAsync() await Container.DisposeAsync(); //important for the event to cleanup to be fired! _messageSink.OnMessage(new DiagnosticMessage("RabbitMq fixture stopped.")); } +} - private sealed class RabbitMQContainerOptions - { - public string Name { get; set; } = "rabbitmq_" + Guid.NewGuid(); - public ushort Port { get; set; } = 5672; - public string ImageName { get; set; } = "rabbitmq:management"; - public string UserName { get; set; } = "guest"; - public string Password { get; set; } = "guest"; - } +public sealed class RabbitMQContainerOptions +{ + public string Name { get; set; } = "rabbitmq_" + Guid.NewGuid(); + public ushort Port { get; set; } = 5672; + public string ImageName { get; set; } = "rabbitmq:management"; + public string UserName { get; set; } = "guest"; + public string Password { get; set; } = "guest"; } diff --git a/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs b/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs index 6fed866d..bafa07ab 100644 --- a/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs @@ -1,12 +1,17 @@ -using System.Net; +using System.Net.Http.Headers; using System.Security.Claims; -using Ardalis.GuardClauses; using AutoBogus; using BuildingBlocks.Abstractions.CQRS.Commands; using BuildingBlocks.Abstractions.CQRS.Queries; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; +using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Messaging.MessagePersistence; using BuildingBlocks.Core.Types; +using BuildingBlocks.Integration.MassTransit; +using BuildingBlocks.Persistence.EfCore.Postgres; +using BuildingBlocks.Persistence.Mongo; +using DotNet.Testcontainers.Configurations; using FluentAssertions; using FluentAssertions.Extensions; using MassTransit; @@ -20,6 +25,7 @@ using NSubstitute; using Serilog; using Tests.Shared.Auth; +using Tests.Shared.Extensions; using Tests.Shared.Factory; using WireMock.Server; using Xunit.Sdk; @@ -41,6 +47,7 @@ public class SharedFixture : IAsyncLifetime public Func? OnSharedFixtureInitialized; public Func? OnSharedFixtureDisposed; + public bool AlreadyMigrated { get; set; } public ILogger Logger { get; } public PostgresContainerFixture PostgresContainerFixture { get; } @@ -58,9 +65,34 @@ public class SharedFixture : IAsyncLifetime public IHttpContextAccessor HttpContextAccessor => _httpContextAccessor ??= ServiceProvider.GetRequiredService(); + /// + /// We should not dispose this GuestClient, because we reuse it in our tests + /// + public HttpClient GuestClient + { + get + { + if (_guestClient == null) + { + _guestClient = Factory.CreateClient(); + // Set the media type of the request to JSON - we need this for getting problem details result for all http calls because problem details just return response for request with media type JSON + _guestClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + return _guestClient; + } + } + + /// + /// We should not dispose this AdminHttpClient, because we reuse it in our tests + /// public HttpClient AdminHttpClient => _adminClient ??= CreateAdminHttpClient(); + + /// + /// We should not dispose this NormalUserHttpClient, because we reuse it in our tests + /// public HttpClient NormalUserHttpClient => _normalClient ??= CreateNormalUserHttpClient(); - public HttpClient GuestClient => _guestClient ??= Factory.CreateClient(); + public WireMockServer WireMockServer { get; } public string? WireMockServerUrl { get; } @@ -80,13 +112,17 @@ public SharedFixture(IMessageSink messageSink) .CreateLogger() .ForContext>(); + // //https://github.com/testcontainers/testcontainers-dotnet/blob/8db93b2eb28bc2bc7d579981da1651cd41ec03f8/docs/custom_configuration/index.md#enable-logging + // TestcontainersSettings.Logger = new Serilog.Extensions.Logging.SerilogLoggerFactory(Logger).CreateLogger( + // "TestContainer" + // ); + // Service provider will build after getting with get accessors, we don't want to build our service provider here PostgresContainerFixture = new PostgresContainerFixture(messageSink); MongoContainerFixture = new MongoContainerFixture(messageSink); //Mongo2GoFixture = new Mongo2GoFixture(messageSink); RabbitMqContainerFixture = new RabbitMQContainerFixture(messageSink); - Factory = new CustomWebApplicationFactory(); AutoFaker.Configure(b => { // configure global AutoBogus settings here @@ -110,33 +146,7 @@ public SharedFixture(IMessageSink messageSink) WireMockServer = WireMockServer.Start(); WireMockServerUrl = WireMockServer.Url; - WithConfigureAppConfigurations( - (context, builder) => - { - // add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration - // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ - builder.AddInMemoryCollection( - new TestConfigurations - { - { "PostgresOptions:ConnectionString", PostgresContainerFixture.Container.ConnectionString }, - { - "MessagePersistenceOptions:ConnectionString", - PostgresContainerFixture.Container.ConnectionString - }, - { "MongoOptions:ConnectionString", MongoContainerFixture.Container.ConnectionString }, - { "MongoOptions:DatabaseName", MongoContainerFixture.Container.Database }, - //{"MongoOptions:ConnectionString", Mongo2GoFixture.MongoDbRunner.ConnectionString}, //initialize mongo2go connection - { "RabbitMqOptions:UserName", RabbitMqContainerFixture.Container.Username }, - { "RabbitMqOptions:Password", RabbitMqContainerFixture.Container.Password }, - { "RabbitMqOptions:Host", RabbitMqContainerFixture.Container.Hostname }, - { "RabbitMqOptions:Port", RabbitMqContainerFixture.Container.Port.ToString() }, - } - ); - - // Or we can override configuration explicitly and it is accessible via IOptions<> and Configuration - context.Configuration["WireMockUrl"] = WireMockServerUrl; - } - ); + Factory = new CustomWebApplicationFactory(); } public async Task InitializeAsync() @@ -150,6 +160,54 @@ public async Task InitializeAsync() //await Mongo2GoFixture.InitializeAsync(); await RabbitMqContainerFixture.InitializeAsync(); + // with `AddOverrideEnvKeyValues` config changes are accessible during services registration + Factory.AddOverrideEnvKeyValues( + new Dictionary + { + { + $"{nameof(PostgresOptions)}:{nameof(PostgresOptions.ConnectionString)}", + PostgresContainerFixture.Container.GetConnectionString() + }, + { + $"{nameof(MessagePersistenceOptions)}:{nameof(PostgresOptions.ConnectionString)}", + PostgresContainerFixture.Container.GetConnectionString() + }, + { + $"{nameof(MongoOptions)}:{nameof(MongoOptions.ConnectionString)}", + MongoContainerFixture.Container.GetConnectionString() + }, + { + $"{nameof(MongoOptions)}:{nameof(MongoOptions.DatabaseName)}", + MongoContainerFixture.MongoContainerOptions.DatabaseName + }, + //{"MongoOptions:ConnectionString", Mongo2GoFixture.MongoDbRunner.ConnectionString}, //initialize mongo2go connection + { + $"{nameof(RabbitMqOptions)}:{nameof(RabbitMqOptions.UserName)}", + RabbitMqContainerFixture.RabbitMqContainerOptions.UserName + }, + { + $"{nameof(RabbitMqOptions)}:{nameof(RabbitMqOptions.Password)}", + RabbitMqContainerFixture.RabbitMqContainerOptions.Password + }, + { + $"{nameof(RabbitMqOptions)}:{nameof(RabbitMqOptions.Host)}", + RabbitMqContainerFixture.Container.Hostname + }, + { + $"{nameof(RabbitMqOptions)}:{nameof(RabbitMqOptions.Port)}", + RabbitMqContainerFixture.HostPort.ToString() + }, + } + ); + + // with `AddOverrideInMemoryConfig` config changes are accessible after services registration and build process + Factory.AddOverrideInMemoryConfig(new Dictionary() { }); + Factory.ConfigurationAction += cfg => + { + // Or we can override configuration explicitly and it is accessible via IOptions<> and Configuration + cfg["WireMockUrl"] = WireMockServerUrl; + }; + var initCallback = OnSharedFixtureInitialized?.Invoke(); if (initCallback != null) await initCallback; @@ -157,8 +215,6 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - await MasstransitHarness.Stop(cancellationToken: CancellationToken.None); - await PostgresContainerFixture.DisposeAsync(); await MongoContainerFixture.DisposeAsync(); //await Mongo2GoFixture.DisposeAsync(); @@ -216,7 +272,13 @@ Action cfg return Factory; } - public void ConfigureTestServices(Action? services = null) + public void ConfigureTestConfigureApp(Action? configBuilder) + { + if (configBuilder is not null) + Factory.TestConfigureApp += configBuilder; + } + + public void ConfigureTestServices(Action? services) { if (services is not null) Factory.TestConfigureServices += services; @@ -274,7 +336,7 @@ public async Task SendAsync( ICommand request, CancellationToken cancellationToken = default ) - where TResponse : notnull + where TResponse : class { return await ExecuteScopeAsync(async sp => { @@ -291,7 +353,7 @@ await ExecuteScopeAsync(async sp => { var commandProcessor = sp.GetRequiredService(); - return await commandProcessor.SendAsync(request, cancellationToken); + await commandProcessor.SendAsync(request, cancellationToken); }); } @@ -325,7 +387,11 @@ await ExecuteScopeAsync(async sp => } // Ref: https://tech.energyhelpline.com/in-memory-testing-with-masstransit/ - public async ValueTask WaitUntilConditionMet(Func> conditionToMet, int? timeoutSecond = null) + public async ValueTask WaitUntilConditionMet( + Func> conditionToMet, + int? timeoutSecond = null, + string? exception = null + ) { var time = timeoutSecond ?? 300; @@ -336,7 +402,9 @@ public async ValueTask WaitUntilConditionMet(Func> conditionToMet, in { if (timeoutExpired) { - throw new TimeoutException("Condition not met for the test."); + throw new TimeoutException( + exception ?? $"Condition not met for the test in the '{timeoutExpired}' second." + ); } await Task.Delay(100); @@ -432,7 +500,7 @@ await WaitUntilConditionMet(async () => return await ExecuteScopeAsync(async sp => { var messagePersistenceService = sp.GetService(); - Guard.Against.Null(messagePersistenceService, nameof(messagePersistenceService)); + messagePersistenceService.NotBeNull(); var filter = await messagePersistenceService.GetByFilterAsync( x => @@ -443,7 +511,8 @@ await WaitUntilConditionMet(async () => var res = filter.Any(x => x.MessageStatus == MessageStatus.Processed); - if (res is true) { } + if (res is true) + { } return res; }); @@ -460,7 +529,7 @@ await WaitUntilConditionMet(async () => return await ExecuteScopeAsync(async sp => { var messagePersistenceService = sp.GetService(); - Guard.Against.Null(messagePersistenceService, nameof(messagePersistenceService)); + messagePersistenceService.NotBeNull(); var filter = await messagePersistenceService.GetByFilterAsync( x => @@ -480,10 +549,13 @@ private HttpClient CreateAdminHttpClient() { var adminClient = Factory.CreateClient(); + // Set the media type of the request to JSON - we need this for getting problem details result for all http calls because problem details just return response for request with media type JSON + adminClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + //https://github.com/webmotions/fake-authentication-jwtbearer/issues/14 var claims = CreateAdminUserMock().Claims; - adminClient.SetFakeBearerToken(claims); + adminClient.SetFakeJwtBearerClaims(claims); return adminClient; } @@ -492,10 +564,13 @@ private HttpClient CreateNormalUserHttpClient() { var userClient = Factory.CreateClient(); + // Set the media type of the request to JSON - we need this for getting problem details result for all http calls because problem details just return response for request with media type JSON + userClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + //https://github.com/webmotions/fake-authentication-jwtbearer/issues/14 var claims = CreateNormalUserMock().Claims; - userClient.SetFakeBearerToken(claims); + userClient.SetFakeJwtBearerClaims(claims); return userClient; } diff --git a/tests/Shared/Tests.Shared/Fixtures/SharedFixtureWithEfCoreAndMongo.cs b/tests/Shared/Tests.Shared/Fixtures/SharedFixtureWithEfCoreAndMongo.cs index c23a5824..5e582791 100644 --- a/tests/Shared/Tests.Shared/Fixtures/SharedFixtureWithEfCoreAndMongo.cs +++ b/tests/Shared/Tests.Shared/Fixtures/SharedFixtureWithEfCoreAndMongo.cs @@ -46,12 +46,12 @@ public Task ExecuteMongoDbContextAsync(Func ExecuteMongoDbContextAsync(Func> action) => ExecuteScopeAsync(sp => action(sp.GetRequiredService())); - public async Task InsertMongoDbContextAsync(string collectionName, params T[] entities) + public async Task InsertMongoDbContextAsync(params T[] entities) where T : class { await ExecuteMongoDbContextAsync(async db => { - await db.GetCollection(collectionName).InsertManyAsync(entities.ToList()); + await db.GetCollection().InsertManyAsync(entities.ToList()); }); } diff --git a/tests/Shared/Tests.Shared/Fixtures/Tests/EfDbContextTransactionFixture.cs b/tests/Shared/Tests.Shared/Fixtures/Tests/EfDbContextTransactionFixture.cs index 30a2b202..025b80e7 100644 --- a/tests/Shared/Tests.Shared/Fixtures/Tests/EfDbContextTransactionFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/Tests/EfDbContextTransactionFixture.cs @@ -15,7 +15,7 @@ public class EfDbContextTransactionFixture : IAsyncLifetime public async Task init_container() { _fixture.Container.Should().NotBeNull(); - _fixture.Container.ConnectionString.Should().NotBeEmpty(); + _fixture.Container.GetConnectionString().Should().NotBeEmpty(); _fixture.DbContext.Should().NotBeNull(); } diff --git a/tests/Shared/Tests.Shared/Fixtures/Tests/MongoContainerFixtureTests.cs b/tests/Shared/Tests.Shared/Fixtures/Tests/MongoContainerFixtureTests.cs index cf24cce2..940c92b6 100644 --- a/tests/Shared/Tests.Shared/Fixtures/Tests/MongoContainerFixtureTests.cs +++ b/tests/Shared/Tests.Shared/Fixtures/Tests/MongoContainerFixtureTests.cs @@ -15,25 +15,27 @@ public class MongoContainerFixtureTests : IAsyncLifetime public async Task init_container() { _fixture.Container.Should().NotBeNull(); - _fixture.Container.ConnectionString.Should().NotBeEmpty(); + _fixture.Container.GetConnectionString().Should().NotBeEmpty(); } [Fact] [CategoryTrait(TestCategory.Unit)] public async Task reset_database() { - MongoClient dbClient = new MongoClient(_fixture.Container.ConnectionString); + MongoClient dbClient = new MongoClient(_fixture.Container.GetConnectionString()); await dbClient - .GetDatabase(_fixture.Container.Database) + .GetDatabase(_fixture.MongoContainerOptions.DatabaseName) .CreateCollectionAsync(nameof(TestDocument).Underscore()); var testDoc = dbClient - .GetDatabase(_fixture.Container.Database) + .GetDatabase(_fixture.MongoContainerOptions.DatabaseName) .GetCollection(nameof(TestDocument).Underscore()); await testDoc.InsertOneAsync(new TestDocument() { Name = "test data" }); await _fixture.ResetDbAsync(); - var collections = await dbClient.GetDatabase(_fixture.Container.Database).ListCollectionsAsync(); + var collections = await dbClient + .GetDatabase(_fixture.MongoContainerOptions.DatabaseName) + .ListCollectionsAsync(); collections.ToList().Should().BeEmpty(); } diff --git a/tests/Shared/Tests.Shared/Fixtures/Tests/PostgresContainerFixtureTests.cs b/tests/Shared/Tests.Shared/Fixtures/Tests/PostgresContainerFixtureTests.cs index 68620395..c18fcf6d 100644 --- a/tests/Shared/Tests.Shared/Fixtures/Tests/PostgresContainerFixtureTests.cs +++ b/tests/Shared/Tests.Shared/Fixtures/Tests/PostgresContainerFixtureTests.cs @@ -13,7 +13,7 @@ public class PostgresContainerFixtureTests : IAsyncLifetime public async Task init_container() { _fixture.Container.Should().NotBeNull(); - _fixture.Container.ConnectionString.Should().NotBeEmpty(); + _fixture.Container.GetConnectionString().Should().NotBeEmpty(); } // [Fact] diff --git a/tests/Shared/Tests.Shared/Fixtures/Tests/RabbitMQContainerFixtureTests.cs b/tests/Shared/Tests.Shared/Fixtures/Tests/RabbitMQContainerFixtureTests.cs index 5e8e040f..dbcdeff5 100644 --- a/tests/Shared/Tests.Shared/Fixtures/Tests/RabbitMQContainerFixtureTests.cs +++ b/tests/Shared/Tests.Shared/Fixtures/Tests/RabbitMQContainerFixtureTests.cs @@ -13,7 +13,7 @@ public class RabbitMQContainerFixtureTests : IAsyncLifetime public async Task init_container() { _fixture.Container.Should().NotBeNull(); - _fixture.Container.ConnectionString.Should().NotBeEmpty(); + _fixture.Container.GetConnectionString().Should().NotBeEmpty(); } [Fact] diff --git a/tests/Shared/Tests.Shared/Helpers/ConfigurationHelper.cs b/tests/Shared/Tests.Shared/Helpers/ConfigurationHelper.cs index dce85cc3..4b86e0af 100644 --- a/tests/Shared/Tests.Shared/Helpers/ConfigurationHelper.cs +++ b/tests/Shared/Tests.Shared/Helpers/ConfigurationHelper.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Configuration; using BuildingBlocks.Core.Extensions; -using BuildingBlocks.Core.Web.Extenions; namespace Tests.Shared.Helpers; @@ -23,7 +22,7 @@ public static TOptions BindOptions() //https://www.thecodebuzz.com/read-appsettings-json-in-net-core-test-project-xunit-mstest/ //https://weblog.west-wind.com/posts/2018/Feb/18/Accessing-Configuration-in-NET-Core-Test-Projects //https://bartwullems.blogspot.com/2019/03/net-coreunit-tests-configuration.html - private static IConfigurationRoot BuildConfiguration() + public static IConfigurationRoot BuildConfiguration() { var rootPath = Directory.GetCurrentDirectory(); diff --git a/tests/Shared/Tests.Shared/TestBase/EndToEndTestBase.cs b/tests/Shared/Tests.Shared/TestBase/EndToEndTestBase.cs index f7b7ad98..7a79a66f 100644 --- a/tests/Shared/Tests.Shared/TestBase/EndToEndTestBase.cs +++ b/tests/Shared/Tests.Shared/TestBase/EndToEndTestBase.cs @@ -1,65 +1,46 @@ -// using System.Net.Http.Json; -// using BuildingBlocks.Core.Extensions; -// using BuildingBlocks.Persistence.Mongo; -// using Microsoft.EntityFrameworkCore; -// using Tests.Shared.Auth; -// -// namespace Tests.Shared.Fixtures; -// -// [Trait("Category", "EndToEnd")] -// public class EndToEndTestTestBase : -// IntegrationTestBase -// where TWContext : DbContext -// where TRContext : MongoDbContext -// where TEntryPoint : class -// { -// public EndToEndTestTestBase( -// SharedFixture sharedFixture, -// ITestOutputHelper outputHelper) -// : base(sharedFixture, outputHelper) -// { -// } -// -// protected virtual UserType UserType => UserType.Admin; -// -// public async Task GetAsync(string requestUrl, CancellationToken cancellationToken = default) -// { -// var client = GetClient(UserType); -// return await client.GetFromJsonAsync(requestUrl, cancellationToken: cancellationToken); -// } -// -// public async Task PostAsync(string requestUrl, TRequest request, -// CancellationToken cancellationToken = default) -// { -// var client = GetClient(UserType); -// return await client.PostAsJsonAsync(requestUrl, request, cancellationToken); -// } -// -// public async Task PutAsync( -// string requestUrl, -// TRequest request, -// CancellationToken cancellationToken = default) -// { -// var client = GetClient(UserType); -// return await client.PutAsJsonAsync(requestUrl, request, cancellationToken); -// } -// -// public async Task Delete(string requestUrl, CancellationToken cancellationToken = default) -// { -// var client = GetClient(UserType); -// await client.DeleteAsync(requestUrl, cancellationToken); -// } -// -// private HttpClient GetClient(UserType userType) -// { -// switch (userType) -// { -// case UserType.Admin: -// return Fixture.AdminHttpClient; -// case UserType.User: -// return Fixture.NormalUserHttpClient; -// default: -// return Fixture.GuestClient; -// } -// } -// } +using BuildingBlocks.Persistence.Mongo; +using Microsoft.EntityFrameworkCore; +using Tests.Shared.TestBase; +using Tests.Shared.XunitCategories; + +namespace Tests.Shared.Fixtures; + +public class EndToEndTestTest : IntegrationTest + where TEntryPoint : class +{ + public EndToEndTestTest(SharedFixture sharedFixture, ITestOutputHelper outputHelper) + : base(sharedFixture, outputHelper) { } +} + +public abstract class EndToEndTestTestBase : EndToEndTestTest + where TEntryPoint : class + where TContext : DbContext +{ + protected EndToEndTestTestBase( + SharedFixtureWithEfCore sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + SharedFixture = sharedFixture; + } + + public new SharedFixtureWithEfCore SharedFixture { get; } +} + +public abstract class EndToEndTestTestBase : EndToEndTestTest + where TEntryPoint : class + where TWContext : DbContext + where TRContext : MongoDbContext +{ + protected EndToEndTestTestBase( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper + ) + : base(sharedFixture, outputHelper) + { + SharedFixture = sharedFixture; + } + + public new SharedFixtureWithEfCoreAndMongo SharedFixture { get; } +} diff --git a/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs b/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs index 36aa535c..2c90a523 100644 --- a/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs +++ b/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs @@ -1,3 +1,4 @@ +using BuildingBlocks.Abstractions.Persistence; using BuildingBlocks.Persistence.Mongo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -29,21 +30,24 @@ protected IntegrationTest(SharedFixture sharedFixture, ITestOutputH CancellationTokenSource = new(TimeSpan.FromSeconds(Timeout)); CancellationToken.ThrowIfCancellationRequested(); - SharedFixture.WithConfigureAppConfigurations( + SharedFixture.ConfigureTestServices(RegisterTestConfigureServices); + + SharedFixture.ConfigureTestConfigureApp( (context, configurationBuilder) => { RegisterTestAppConfigurations(configurationBuilder, context.Configuration, context.HostingEnvironment); } ); - SharedFixture.ConfigureTestServices(RegisterTestConfigureServices); - // Build Service Provider here Scope = SharedFixture.ServiceProvider.CreateScope(); } // we use IAsyncLifetime in xunit instead of constructor when we have async operation - public virtual async Task InitializeAsync() { } + public virtual async Task InitializeAsync() + { + await RunSeedAndMigrationAsync(); + } public virtual async Task DisposeAsync() { @@ -56,6 +60,31 @@ public virtual async Task DisposeAsync() Scope.Dispose(); } + private async Task RunSeedAndMigrationAsync() + { + var migrations = Scope.ServiceProvider.GetServices(); + var seeders = Scope.ServiceProvider.GetServices(); + + if (!SharedFixture.AlreadyMigrated) + { + foreach (var migration in migrations) + { + SharedFixture.Logger.Information("Migration '{Migration}' started...", migrations.GetType().Name); + await migration.ExecuteAsync(CancellationToken); + SharedFixture.Logger.Information("Migration '{Migration}' ended...", migration.GetType().Name); + } + + SharedFixture.AlreadyMigrated = true; + } + + foreach (var seeder in seeders) + { + SharedFixture.Logger.Information("Seeder '{Seeder}' started...", seeder.GetType().Name); + await seeder.SeedAllAsync(); + SharedFixture.Logger.Information("Seeder '{Seeder}' ended...", seeder.GetType().Name); + } + } + protected virtual void RegisterTestConfigureServices(IServiceCollection services) { } protected virtual void RegisterTestAppConfigurations( diff --git a/tests/Shared/Tests.Shared/Tests.Shared.csproj b/tests/Shared/Tests.Shared/Tests.Shared.csproj index 947087ec..e2c35d03 100644 --- a/tests/Shared/Tests.Shared/Tests.Shared.csproj +++ b/tests/Shared/Tests.Shared/Tests.Shared.csproj @@ -14,7 +14,12 @@ + + + + +

wvn}eD8Ok=S8@0VT_B96=yPEzGScozjram>C*rn z#@|CSkIyQKx9c;cwIlYJ&mJ{DPvV0?vDOQk7(Hi{(&0fD%+_fDw#rm}HupJKgWoh$ zXGb)oaCF{dHBxN*-*cgiiQ8gM$v7)cC~jby*idYwV2TO4PCuQNqCDb|S!w7ypyya1 z>OO6v{`#{Fpf0M^lCFhxM;h8~w~gg!b9Cs99cVPzef|_H^1@L5R^V8uDKE}9wTWjINsOcDeSHVj6t?j>ADT-WoasPZIE;My?#gc9FXcP8|29~Nv zpP`~t#w3UBEX8^FJa5>YUR*2DkAR0fXk6$weUkHe+z-ElcGN=7baq+;#29=Ip>l`` z2gA#Jse$!m3q@Yo+bgA)`k*$arIp8ol3Gaqg9#w^#PWs48;`)~(YtXo1mZDe6uvNw zX^(dw@}3?u9aH+SY|x$G9mZ_;u>-!&%8g`xJkcfi%6S+0gd(BDa@Od%YTJMhP&z;l7!?2GABhX;uQv)qDQX^OUP6SJ1lx)8Pi7}JYzlH{G0y@U{Q+5^?=VnTFNhGwLD>XemHD%rG2fnhAVu1a*+mt=#QoW;?PkSfH{>u>JUd%+pVS3CEUSJ5*Lf5hYY#Pj z4@mi>ivVhzRUHS6K@KTFP0g&BRq-0&S9^%{Avh`@bJ$*t8L2c%Hzu#L&o+q@A(k_?Z-^WiD= z&DnLlAj_ghyLrM+5v^WjV_jxhH0L8u!9Z%B45Hrn-BWm^I_fb}tt$a*orx?P$Ui0h z(lXygpt)#(NQX4v1+#or$kB;)c>Y{RStBo2Pyl8<%9yhyD{D|IUS&467)aZvw)CDK zxSiceFGgo~=DDbHLV>@bf9{t*7>m8QlaC968ZA(>a1J++mIhtPXx51ivNKf zVH6Wk_F@Fchz%XAnqAb84v0G{^ zFV^iNl{z}0D_wfUL>?Kl+7ORVW6w6ACbKWv<~?`;dT!o4;T)>d-|U6i{n-kxAJ0UP z+1LodxLB;Ud8@fzRf{=b+O#1u#$$Efv{=*auWFxxzk5t|BOo%bd!N54B zBgak;kXW!)Ylf*lP@t~@dZ)EKadXm@e8E=$!V>AJ2|;3ZJ_qdKHLZ081ik{%_*Nyi zS5op&Uf1{(LYNlkQ1513?B47x|0I&vTLPf*@TA{Y*2|Pt2Q!ruDGHBefbaK2(AtX9 z6Hyo&*Ee9PPFn9|o!|$G>#+(syXfaV8$Cs8_K_h}P5G|}_f%!+eb#sAenFa`-gY2% zTfAdBB?&8Wk;&ndc)UwqNYAXkrd(XNX0<~Dt+!nY%9<}m?1su^0BtAAQt`plM;9vI z(&C-GFEu}xtPck=9PYRk%*bC+m zJA^Oi6Eo9agufL$4a)!ZSsoy^{=9il7wN!zz&mCR$y0bafYC zd)!HlZ?7l-Rv4(>QOqWLRAcdYfge8SUPix`_3!rd~=0 z5w2$|DZRA6$%*bI0^Xov{F>NdK=tezkdOogQTbYbAu+h#|d{P+yXj4kM30VwsdD+W|Bv_UO@X zF|~IT#QtJz&r-!6(@HVAe{#j`1G|i9q~R?c16#|ouozv6*S==6t`(TgT_m4kfOT6i}TrLJkHCFF^gMk=*wX( zKFFwO+bkQ{TCxRz5Xjrq*I0Th2dL;2gfASAL>%WG^GoG3qS2v`@pIi|+UI!uuwjqA za2Qb~&Un+U$7_EvkkIRo<#HN|kuB+}^X0bK4k>YOB#l6kd10iQcx$MVbfXL0 zVV)kh*lE{WR+R9i4kmk$gk3%^_@=069Q0jr0XF@B_JR}^#$rJ>$Ii$!&!@|5&l*s*m7W58FkIRXNHZ=M6%odOni%hPRe5f?TDo-ULZ;U_!^j@`TrTqU>=f`mojm zyH)FhV>!H$tH7<`bscr9S7KctwrcWftkm(8CaFx6)8*|>e<;qTPYJijD6X}zv>dA> zD)7$w6Gur+-+au#76mgmScOm1b4nUefdI<`J1wF6Go<9ldRBQbU1#s3~r1?n8qIWbL;Z7Xhl6-A%i-uQdh4a5L z9wnTZd>^%FI#dK3-Om+0USUM<{yUAkddMEU;cR$#AkS-oMNW(6VN2VN9O-e*Cg1Ni zmG?bP_Hp7;FqPfQ5Ik(*BDKm6>&sPIia{WS8116AM5AwQZTOuz405a3y)DgsJ{^4L zj%8$Bxa4^2FfVjS8xxL@HEK)P`C9%pGp&tCr2L{)(Du{(fW{f7cT`dy$2xB!Lh#Wy z(`&*qr1>0YLeXv%7O>6c0RCu9;cL47Nt?XBPP!k+fliogDT@tMFD!@=WrHS>wV1t#tDTt_qquZeR=Ld9n~`sp&zzSE|0gRx22m)SueZdcZegEVjjC5 zMRS9?pT^eS&%94kWkg;*z5FE@lszG=Ogr`R1;NGLvv z={VXuUJ~N!t27NWVOF=zO3}CMoF3iG&8Py)v40R#-5r0t-}UAOc(XT>Q0H;vp+yN( z8-~K~WY1`^UCZRS_Q(D9Hg$}qnF`J^Idxuxj7R=h#2KECt(ovSSkvjPE zKOcA|eh5-~diTncR|%!bZ9c}MYs=`*Tl50bHj^c*^;a|Ejb|*5#69}vc@k^{W_yfw zTRvm*JSsc1?S6gpWpu=UF#F-EQ|3vi-n|ys(+bUZHI;(750$MJ=B+wnPb@C{o#hk>T6b@{7hV_sN3rx!%C8L! zMyTbvh7YJn^t`QQ*SR6~!jgbMEcvO3RIgQ+8C>Yj5U8A&l&o6C2;-XQyD%%=Nhvh( zs@=*l&jL4@oHhW^B=Ma})OyxHP8Fh9)Ewnm|3C=!*}&6*WKJrEPtc-bDSQ&P8}Ac1 zne`=Zv`enP&8CnIDd9%NLiLlD@7M)h_Ga9xL=s0m<>RV>*rzGGAb?3t9UIw@h`|yu z=zo1G$3+9z9tK3D-*ccJzgz0yr)$4H*Q6plN;^87(gIHL3{w~SaoAaQU z86R+7zLM}E@*8dWHv1*hOX0U~QSd#Fw_SXU28Encn(LN{2DR;hE_O~&>Vmj(Yi`JO z+;G``*@g=81@=7A92>7ww*mrIp)BpUVZ(DQsD!a(lL_{8&5-GyAJf-8IPdFC zH+e=rpPTMb3+k+B&2_xe^D+24{Op&v<@`yf#LF(e-30stvjMwC5D{Btl1FHNE6ofP z4vF{t^Z-y6=8Y!hl$PjIW~+&>gkpXrGTr*YI@krh-Ss=F`Bll(rIH%HY1J^jU=Kmn zu9S1cPxMGE$7ZWe`EY0z;LxQ{0*T-dZlJDFaFu3%j{|r5HTI!hNF(Fc>MQ{4S1e9E zCoI3D5fC60GJ9}${ywIr?82$-yIA=!@5=pQYS25unreNQEEd3yWjEW(>Zh*gY@VL% zfrQ${PBlGq6+r;%dWO1Lhmy)iVnH9|SIgKWU5+@)k7@HcL{*ULJI?)2dpUpZ8_98K zej^M^4*KFZsqZ#trXfha(+o*=M^Mk6=yQTs=y6DlwYyN&HNYhppc)Gr@SPki?H%?& zRRh#;B6A3&G^>j3Ugh$|Q+QQVQ5_i8L}i1q+#Ce@M;HQALvU>bT9Z^jp6`*f{d-+f zOQlR!xt})O8}RUp_6dL(f1lhZFQ2I>Q&0%zwjaD>!Ojp@5IinJh(b}~e%)Dp2<%2} zs0?j*?>f;JTNN9ZcgTt0!>b1!5Y9wzopLEIV8zvw`v`jw{B<3diXzIn&YMi?AI=u? zeY^U>*fYzd^r_{}*{>f%jZaQX~p2sHMGl5lHl2={sHn zdA>rD9I~y*)9!4CQ-U>rzfD_~>5Mvh(@GZrnD>)DBMm6Ov~j!PJiwy82()&SxBURF z`v!nfte=^Hwk?1G8(ykhfAj+jwju=a=FQ@~H2Bxxi-JSt;`3pPX@Jk3DDVaHj~Cl_ z%&f@s49Q-b?*sNm-((w6vE?Fg?4Q(rhraDPU0cxi0S8JU4Bcd#op0HHTt|RrQ%H&c z;sSl5qSmK5IG2!t^z;<2=9*`9l5CGq^qTaivX_a-+VP0ar`|wj#{CVr7ie`#9g6}Z z&D$bo^%g1uSU#FkmxN}wB4V~K`RY+VSpUfXl zP@y1qEqUdOc^1>(FkGEw>L^YYto+ZsA zHz6+V$<&6*&yxwp*FZ*ZYbnMsX)Vtn|6C>097nrQk|znwSJi* z!_0)fx_3SjS=n>=+1y?n7#ZcJKUw09hNG+4Qav%BA4*-g$>*v@0`x)Dp$m|q%oboZ zugU>eFuCT##=*kz3$a=MV%?tlc3s;9hbe+-RZY8j|h-JP@eK>=H!A6;|%=XvAK&)VG^P~T8-8y(45|F7ae{aHf~mW$P25X5&4ZsZcO|9wJ&a&PQ+|yE9wl~wL zR89bh);YP=x(dDd6M+1Yy74m!XPZ8dZZh2W z*Z01R@gP$`Q+#XIlNOVJKm&sPoUY&xDZ&7Esq0R{K(4gl1Z#oL6ZuQtvjn>|UwSRnMTcvzxA`$fgNdh!ZBymK1EC zp-)R2PXcJz{T~_-kpry2JWRlakblov-~M=?O?Z7nbM5V~d_tkMJ(?+zjr*0V`jB}v z8r_ue#((-suKYTi1GmEZ_GZ>p_~dlrZbm$bl3}P0pU&6B$X6O~GHpe_%mX&4%x?y$ z{;t2zfavIO@D{jsmi-A2%B#igBA_kMz7-M@qT_ ziP*Df?lPee!dT7$NVDNY?(f-)GJ(Xhqtppu_HKY~P-=aA?lf+1Gp{`4!Xk5JMJp}q zctr=e+Hh7%h;v#P95xA;GvdA4Z@oLdjo$b8gf2fC@ZQ~h*2nO| zUQ`6xvFzRjW0dJWpZxM3Z<)4U*O;0LY@IYZ|8~^0WH8HA!2)U`MIo7Z0UjYAaNnLC z)U+0K9mpoKmq(_8v$JK280@EC3g!=Vd5pjqrMs_3oO75|<>b;fbz3gS?R^t2(f{Tx zP|x81AnMv%pqf%#WyBB5{a)%JMT3%dk6W8xOd6;@j>9H=>7}*dWBi#~Vzh8ECOtks z6(Kt{FMg5v=e(}*s-e9kVg5taJf4KJL>I-2@_Z1+Z98R>l`!v<=ZTcOSv6*F- zPqS}weqinFiKEt+fqW|>x2%=e#co|oTYZL>KKSg#1DAVT4Nf}-;I(uQb2QgMjrTWN z@pif|-&<%oiAeZvsBGc%cGXO)tzBlzV};-d$?;P>{QNjqvL0bRvf2XM*l04v!BVVU zsoYhPI5@B8cAp`x4LX0XERGljecSB}F`C(_f~l$Dwt`5DE5{4fTKiQl!DpGZf6B?7qh-08 zV1HI|J=X(!=XmrugqmrH(7v#135_+~(bm7qXS>j&Kv|)a)z8*D%qsuWM*C~r?g`sf z4^09wMAlLv_vo0g)_gpSwv~WD${s85<_zk9=KpdX{t`SUB*X{`J9eI3Gt!K2Ffnmz z(Ue)g4nCl(wi-+FVQJU!IQwgMTfcTL@ey7LPyu&8p2>EDC!38NYKVVIhrX~VXn-`ZDTu4b<%#DbEo zS1{0Z>_ZNx$eex%#y{WwXxd5TUCbM+t!)oiCc60=m%cVVLbf*k` za5$4=-vu!i5ka;27LBxuKg(?|V^zYKsR@7aQmQ_R%=x@UU|Ae8^K!Ww?)?tw=%BvP zxm?mVf;4ix2<#;GJi0v1Vk=eQ&=|R5Cn3pK^ULc|K}ay!xX^^%i2O z>Mhkbopk4Y85Tnhs%2*_1*8kP+Kl_^1_n&WI+VXE7@R>hK8BE;+P|3m)w1@wLNp8)%K0 z+f@l0%voqScuIp<*hu~)Vg2ZLkKUVsAb!6k!6!`NDpPhw!Sl|D4P6uIxy+Bx8OCSd zCcxfdGBen*kaDMw004YIEkS{tZd{_`qs^U~s#cy4_NgMGVMK~@@BZgVfjpJR@-%U8 z7qq&vV)hQ=Xn_RdGT@>{s4=1YWxk8!%o;bxgvzt0v`o)TcwZhO28HNZiUU1r=z+=6 zOVmJQcV%hFAiJ**kK1&5#w?aqgF4gSf6q=D4TR+lFY5L>XTXjzN?r24--S#|S2oFwBlurNFVnL=LJSQpr;paCDe3#`pUW4`f&(wsSG6~mBv>kg^T>UDBZp+Z!c^2!`R$Z)baH|v0op;)o=ezuYgr(Slgtt755k21U z(Aw#)$FbnhMqUF4=dT_7sk5c2=_aa1cEcf&=6o5@9eUQGcKC=nHdNV$0I2)k35s-o zAsb8e+4!SrDFpe8q1{i;Ay_#Q={WsTUrs4fcg_Jf6nKgj5sdTr*EwSIxr$tlUbS*> zOmkml1+qJG5J1fXYT5+4_f;X7>SvJ@MLk@&l{?v(LD~@Ce_aZx+)ijw6I2M@J8!@J%x{#3NlbY2Jh^{p`x2nO z7f#R|aNelP((cp-5m55}|2?NfeeeI`ai;%wml-avjMcA#xo^6i0p&wa8iR%lRr^br z50xRMf66O?@f*}DGHDqEtD%qh^2h4;g&)pVYhW(>LZqsZ?2l-2a3%GsB~g>6FR z0!LxXJwVeV*0QW6H9NPAkCXU$>>Dd_LGRcZyuXN{BnJoqHSf?n%P#60`@gp4R6K}o z9|~P3?{N8z+RxS;ER`<@%i0gSELS=Z&C(ka@(Dc(Knd>>06K0Gry}*-we?fEmR)DS zA>jPCy_p9e^xUwO+m*{fzIN$}opdrQG%kcTT^2@~CcWb2o}_0CS<1>cxt3PIt@s@JNN;U$Eh$wRS(=_89GyoQeDL_fc8t zLhR_l^iJQSy@gfr>RlyAC(y%OXIdL)vSMr7{?gq7IFPM(QybbhVFO<5z%cM=mD_5% zp-|<<`|{}+MA_L-8?S}~gsUWu?B4#uT(@KSxg=;agK{iuE0czZ&W5Qe7erPcOhJ6) zF5FMub$%sdujsHeJlDzB$`{$0RYJj$f@KN{jsYO|OB=bcJR=bXpJQn+rv+T#Mo$UfJX- znOUK1=?~hUUrLL`MmPa-p4w@N3Xx)nNmB_^$*LNU-oVd=ghTR?+`DaD>S|Nt257rC$*VB zC^_V=hHAp&K-^hix~6&g5lzk6_x9X82=^PB>Uf%9hb4?Doeqt^-I3*M@Rn2I+9+UX z(e1bKu zRHLaDFk5b6Fa~HAfwOZ(bt3rol{80m?f=x+J8Ktj3jdac=$bLRy~xVx$|BtWrrH2k zo1bLF;mL6I@9Qo^_pZI#-7mkw*E$3Lw!-bML+R9kY zzjiYDImz+x;_U@}h1W)da)A+S^d8b=ig&%TBl`~4wy*-0+Zxt6v+o`Gp-R=A zbq&v}qp7Nsx^7lwHYfdNt}Lu#Q<$Xi|M9lqgtv2`+m-bsl{dZ0?s?K0uerGS=k&Ri zz&lVn4%l*$9cOBePiRH;1rtHpJomoBwLo(M)IRfcg8;N`&`a&TbA?KbSfz}v=JkF{ zvltnEtFnt4d1}H?d-Spo!{fgCKDw|nM^&ki*1L0KZ_J=hg$%vfxf2^-684ZYa#h7p zQe2Mx?@#atA=BYYOd8v~ zLUeiEK=J)nF7y9{P~uL&i~Sc2AM!~OaD&%Sr7aOaJ1BI z-(lNyGKZPt3Djf1)4-o_@QCp{Evc zkU24*o#zmuyd?vqY)#8j>Jf5WzVVv&95b1Pr~p?h71_g$9Wn-pEqNG5kDYpl`b*-{ z3qAehzkPp35MlG$e_wtkh6+Jl@ z^!`1gpe{IT3*U46)UB773;}hX*K5R=3?4Dy+Wo6=A@xrM=A3`rZf{6Vby+;%$5+7{hU`b)D2?xEcyZJ6p17q)_#olh zoXDjEs6@iyq6rz%doNP^YIU~qfW3B8epRuR$3BT1eWy~}en4YuTD+QoB7EMRiYym_ z{cb{!R!rU#_8aA}1wvQTa(mSw#j1HZnAZlc9{+lWQ*!{|ToAR*uyM<+hOcr0Rkq6vLl+jCdvYKo$qT}`yzRQQK!T^V1CtUMLhaVCeu%|R1OkHU-*y;c7Y z(hKiXKZjle^&2^j03gL{N5}URyOD5q4D(Gang%pLtv$K@Ejxa`7tc-Bb|l*`31_z0 z^(l!r8iIvN{(0|^AI(XdsYtKBIAC}!?A#MD?%1sDLcx~BC*?i)@t8vBkjPPk;iYt4 zR~uK$4RryS(!89dVb2lyXmOeHl2zxE-oZF4^7!a*>FIF{BDcV?Y8|yYG(mRTCNEWq zj7V(7ZfvO=Y3A=oz%BiJ+1H!Is~)+3=)CL3NQb1FgaS}i-dh=Gt(I_i40K4A-AH0= zOjw%(O;s+tSslzpZP3W$=Z+GN4`wDDo7FD!B6wsLjO${RG<7&7thwEb-OI4)dwxQZ zuhJ%=@QD&-s5nHq@A_Gd(aDlnx%rYnRW~b*L8hh5h@qQ#Y~4!1}X zjuQl{LZR?IfF^^{{gLf)mF7>hAJ2*|jn)?;FM)>6By-V%hVuP~-xldX*VTK2_%?qK z1j=}g?6_kkb@q%~_Ts{Yo$hqsMXN|z6{T>6K<=Xyc$b_0K%FqAd$*l1|7mz;Sqp;a z(%_|c8tF2rZPD=w??=jB>AFmBJ{PQpj~L_t&d^N;5>nAzyB~(Fg6;yA@h4KpoJ00;)+45@Q&&fnb%<9+^uIWy8EoX3p4irx&m5T)!ydM%8I`QIHi z!KE3LFZYRJx*ht&jRgpOQ_DP4ls}5a`oGfDN;hIeb?NR?DT{UN4*%aNi&WYCbNV(m zl_7b}`|_L(OG_vo4Z&(-O@OKXJ^g`&entHD0|ckjXuu>5;=Sew5YM4)xn%&PxYXNe zR8{P>-shZ=d(FBVp}GIS%=P`iVcM&B-L=lJ*Y~I9?*E@?oTO~9C_%x?@eWy-G~VB3 zw4W=0fVcg|iSE76bjNQ;?$C&E6ZN);LS(8>eo>CA)534{G>HAvjMKnBMac!^JDQSX z-wA%e^#_evo9xJImFjE-=QwD##ugL#`91Q&iYYgIm!H9Otrau>M&F0_0eI_G_$KrO zHs&rubU{Ph->?6@g8H4XJ&3RPgWA-UW6w+QVc@W^1OI4^xi9RYIU*2K^=c$XIt7Ls zT&B^+dqsSjqG&F^KE#=f)TVqGklK9zluz@C!*dIem@v@~AM9FO z&9|Wc6g*ogbmecd*%S2iFL5i5bDPBIt{AapeI9-*7A3vARzRc`1ZRHG;WKkBK3$w+ zR>tupO=BQl_u7BLvO@LIVPTQdEv_;^aQ$D^y=h!i_x3(oYpbWHDs7K5Lad?)2vI~( zrj%nr1|c#8ikMKVkue~YfJsQArAO+3C{qduQKkq1qD;|1l2$56B4bEm2qBf3gn(oq zWP-c#-2c75H}`X2{9pa|TRy$}(Rtmj#4&%vay;S@odT-0|VUEqPp^PAvlaXlxt zNk4}odrC+l5A`2!%&PW|UR{^89!35&OP<{f3=wbe2st$poC`rey7uaeh2u@IfrBZP z8}F{!5v1=K*|Er3-{826m z!0%(<#xJdFTY;-<-+GT4xc3#;+#xvDX*|+qAJ|O*VGNsA z>G{#Gw$Ki$<_U_+4IL;(V5TT1l?5n%7^#YW81{Z zYzeb`{hi|T(tuy0GXbsA?d*F3SOk1ZhV_8)$6#&3s}&z_Pc6B)xOyx;|3mx9d(I`l zYAoAQ77Z=IjP#9sW zu*1j36cOJ51$!p*(TLZX)FfKY;pFyeY%nw7#bTv@1OkJ6bouA=7WLXi0k&oERK50D zAXX~ff(k3Vy>0U8MD0ddHfQbk+WZ5hYp-NqI(~7u0r2>*8#4A=L+R4r{0yceA!CMYcL=cncd+MpM z_uv2|cw^AMvIE#L%*;#P&y^sX_63G~9!U5!H~>eN%e5WWeSC<-DhOyQ?9(FBet+on5t4Yv%J1~A&fmsiD71hw$&pG;NfhZ&e%hkmbXSg z2IB5g%F;`4WaoO_)w!6mW)5G0J7f~X4ejS0-rDSG>Yy4MuNG$fps5e|v?A+>mcTUJ z@8?k+@WnjtQCGZ(Ve3}5C8WRHfjcmDM6dXOE_#$q(-a**PpVehl**dGH6OJBN1{YT znzS$8v^ky5RX-ze#VCCBtR6YodkFo-{n&D*-p>(mFGH?8|3XAVsK8Uy?YVp$Cl-%6 ze@Mo$Ga=!{jVygUeeHVhN#Q#r3BGgY6k^cym9#BSYtc|g^ye!I!>+*H$wFb^L+O^e zm_0}&imsehegls`Fw&@r&n9rmFeq#>>d=f0Q=bnGb&Y1!&neCoN=3HfBh>rN`kw^Y z8NT<0G0*Sv$RxGFR9n~BUAu@g&EMS~m)pTmb~YCsuh%0Y93GvxnZ_x+LksQvEBb0R z+jYyoRUh~j$QAZaD^>l2yE=3F^B#F({HdU6Z@16Su9f5kx$=(4=2Q9-ZO-I~OjfOTGthLvVs-RDKMyO>GdfehEYTQ#Db| z(H$;~+eJRhz|*P{%l!HHE+`l#r1XbQW$@0yCYyUa zV=te)H7uzoU=GZ>%W?WE*&)_TIwvAgdyT1oA89ebYr=aL`jJmIL=&0%tt^B|na^9f zGzZ#5ww7~w{i*q!MjD6&D?&7J>X2&Nggr=kweKF4KmwhShw~CTYa$>)WK&mPuFz<@ zzBNEKQCCijxKh?E<3M?US4FD8Lpwv-5+y6Tt(Bgp673Int)|!AVqm(jtLq=wdYw<6ltj! zm{0;VY@4(wQa(;G_m)}~-rh24Mf@wzEr)fm&Woc|!9pf#h@<&sA&a~`UJU-G6GR#r z)~7aVygy7dyXGxE=Hy$F=l>qLQ62XpWXvpQa>}NC)JqW2S->cC323U9IDZ<69;P*E zh7w%agTpInWw*O@1?6M?Zpj@otK^IHxlP5iEkh0jmVXJf2!S3!t!5Q8mi~1;zFj6=2(*sRmljc$PqLprL z2wl~`w{Z#M_6tF;)?4Y^<1oDGt)!$0Y;aWE1^(p{&`fB!g<;t*SH&x^r}bABjIEt# zf51CYMjpxL_gLmI}0*RaUGx{Z5g#>v6}@Ci(yHp6)y3Q@;Jp#vl&jw zEbrL85d4ljd0uOotSwMGb)KVlxSl15*fY1hP#wkG7IJochWg*<&1|~ZlXBsH5#>Gd zzO~Vvr+A-jheX9>M$Mtm!`dE##u5mg&{XDT(PrP}oHY1hxgD@13TX?A6n4rCrbhB2 zF>-3Q4P5cwe{M=QVXSX@x)mQ;(6sDrJXL*3s|c>0j`;DfOp1R*3oDa#b9g^;Wsd(` zIsKj_ZBW%VPX(Vo>6yF=NVaC47$vOjL#WG>*%UWoNO-vyDJmUrG6Dx?A1UaV%fmDg zM(@~0fqUkfez{q$j~MGPxvTWWWTJj^H&^_|EP<8PcK4ReR3SAn>dj-MybDZ|>9<1` z*PZS9L+k>5F+P&88tIfg6`{-9gaWbOcA%A+@R%u{(UduaK1{f{19f~BP~OcXWlGZQ zLQ`LQ%a8QV_;E{}lV7BnNBm0mWIC*B{n1Y&_<9$N;{FKIX#;Z)-)XzR;VaE8dGfgnr$g*qrrwF_CzWqfn#ky z54e5I7?wWL#S9apwJ_GT5S$SG7nF3dMrnAD5&xr?OY%lPn5KZ%9qB^Y zyW6V4T*5GuwUm>bMk`MO0IaXX(2rmEd7-sFqhrG2rOOYD$-ID3IY8meCXE$Ly#^Cf zj0@LoDM^ham$gu+2cIcB4EGDpPIsB%xlT(u{9VfKjPR=uxSX~7X<(`dE_KX|1T)n` zqov`s`xbFh!>C~qzld9Cz1O74d$a(MSgD;&D~^Qe6XXJ@%*jf7bAsud~!}(;s?8P5C?Jdo$v@q?c1gF&3Z2?133Nj8ou@&C`(^ z^rgux5mPTE9pJRRek_k_E5GZzwq(`2c329u-WJe#I?wV^(gy=*q?S*(A-y!**EnPF zr!n>x!Q0bcFA8$?6f3iB?jBw~48`S~l5K~FYg~WSNu(yp^;lLgD&OZ4VAtUMkmw!- zLHT#;c63;6Nu~TOf2{6_X!`o+DapuQk zrKW*w&op&5TvKat*~Fi=PsZF86Jkt%V?((5bybdzDvcFdpLslU2iT^PUQ#_{-#OWA97c=NnP}rcz8#bFR$T3sS zJ#U?u%mNu7<6suEZbQA;wX0zwMCChBae?&RnWf5yJfGmRS-bhGnp1BQDs2lyo8KKV zPBhA@`x&~&g{G>ft zKTC7f0i4=I&>}y2*$5CPfVpy|IfBPr`*s%6-K6F%z;1flQzDc^udmCTXTd+e3GhS8 zk<5`4kF7%v-djQ_oett7TicVjx)K#f@Bl@fLX-?Se@Fa$m&MTp10H}y7W9qooMlW; z0Ad`GPYE|{g!a0M2{f|Iu!4(2P( zaYA^;%ZS2nWSOFIN^_%Of z!P2k1Ee^YQTfCmS?TTjfVi@$EX}iVdWhSL@s{E`dDvlL&dQ#&g&9)T^qM7kQd#Lv~Wr?|tN&6n`W)$|^URN)U>*cM1A3vX9B%j~gCh5%*&4(K(=WS#6dj zh8dOZ_Pm+k_B&&ChvYoEYQJ=_UblRCU|DHo6i8|emn4G9J9^tnU!1j0?>7T#x&N10 zKb3+&YLLG7Bi!`5!(yi%C`Cun#qp3F^YSn&=-05;huiIeAr`_O@8u1drXq!y$vNPy ztb$3jRO7-<(XPR3)swDP{biC-tyBO1<(1@esO8*$cTK-;9DB+Ue;h05XlIaSut zj0YO~PSQ#FRV2(V3hYf$QI^g^4gfK1Ydd^B3(cSCboTYwe*7Mb%6=Cs{%{+b`3f)C zv(>iLMYg<7PstF;OpTyYtdBiAF)P~TU5L7SsUevLq@Rdlqv;A3{>y_yBXM`6Ic95y zM)Q605rUu8cW@m>j_5U7BEQUP^=ctoi(^O3Hl>x_ti&H3jK0LD8$I9IP#%rnvet47 z2r6JAX={Xq{u0LJ(`YbT<;I?1E$YW+;6I&|p+bth#9Z?(XAjgq8Fg1{D~+t4I#Kzw z($R-WWR5XfgA4Vd&A_S!X@88|u+Z$c^F2UN5 z!h(MdNg4k#dgT|Kf<)M-a=pX8B7gNI7bt*G;x>Rsiz~j6z7wAPH0QP}er($~(U@z*+gjj=8XBfZk`L2>Rp0T1Te|o$x?Lh?^M#O8|rDg4F z2W3AmVukIaIE(LVz%J+UNQ<}k$8W>*L0kVVS9TMP-3!bhQ@`W7CF@40Z_?ujOb=6@ z(vr4MDp7bvtI$$NKKp-%R;-Ng1CzyoejGKVgS`#^&Eem(sUy?3*}eWHg6_ra#Jx+! zr!V)D|GgXi^tx|GkI^-UdL(dyayR|^`=tVMQ}raYJwQ334C?VO3ZPs%?CqvJ3w;1NrErzm&>!YIBY4HT2##UarJ&S-@)Su0#X@i zd-yX2(GxA(B27YQX#@d+XPhgKCSZ-vrtJt9i_?3|%O_`O1qcFdv1>n4UL$nM8U|LF znw%nen!4-`%;7^bQm9lT{`3$N%1iHolBP+g>sk3lqYgGm`x7?FdjR_bV7sbZWhD-? zVV=Vau1)o{8~4L^v67!!It4cM@r)nTsj6Fd!9f44{XibNp`g`cnHwTY*ZrqIw5>x@ zqs@#$bMw!#2U+{DurFAV$1fl%grN`qruD?U0gdKi70OpKe<#2xXM}xTAt`P@<8A9 zM)n=X=){_^tvzuBzB{^~QV_8X!iuu?OTLI7ib2m_iFrHTZC87)XU^n4WRCW0%!itc zM@aA_kw(KyqjKFbNWRi|4V77Ue!cr+?FmHyU4o~*%`2>rDFKTVzs{BOcWtWWT~hCX zNC2i++`$!OfgttRpy|Q%KcS@hi4KM4nIR~TmT}0QN=o7$iHgWlcTXA(4KqgxgbU@; za)a(LeGyAWe|2zg`9*?b+zVMBF-XU?k;%>o^B0wtaWJ8H%JHjMy~3bG$UUG2`~`UG$ojGfyf3hUv;%1L15gZyC2Sn zY({};^XZ&i@Q{=vh!CwlI0{fd!XThH@Xajmz$#Te@=_MNs@s~*X1Nm(wz=TuTh#zQ z{$JUERqq`_b5`oGg|)t{No^=f5+GtJx?yl3Oh7Gwv#*>Uj%cpo>U-4_O@c^WKF>Oz zQyZs7aQ##|&M=)!3n@lvo}K%C|5=D0vqKz6-}*!OWNOQqif!_2n_AT*D)!K6ye0aE zIvQ8da@6#Xm94(?R68ipp({zImstgpjGtCj0+11*;~u5Y>-CwY7EB&%J`%})rpB7Z zKUh9;qO6}3;ZhfnBQ!RzxI%M5g3NqQ%iaFaDsZd_DGRGxI6=@~8jhi1NVS{EF~4i- z=b8w=Y7R;hUIK`vye*QzIN)}gSrpvXxM?mIXwF?|BG)AuXRZt_qG)C2s6`&{u-P%w zGbe~Ue4*n#g0YR0q&2xav5&@Ev+pIL|5g>)C)4I$3zONXM^)%Ia)=Cv(d^$#$&5rR z45?t2XCWIzAVcxC0W<}wM0Q?RsFXEx7Gp)Lr+smUY@!8>Fiq*VNEtPe_+}(XSH&2* z-taTc^h=c^+R8H;;h~Y0?kzuwK+%u}ouSHc*|q!w=KsysZ4b$f5yf{VLz((aAYoUX zlP+WG8s)ZfzFm6*FSV&HP_pa|9QBedeVVE{Ka}y^)V~5N<4(Fa2s00Xp(4e=v(NR` zw!44A?yLEJ>#Sw9HDemA4{6sDe%O&_uRWOSK0YRYsYfo*^N*G;w4PU&oRRc@m;3A} zt}L#2D*QPMX~lJY7u5MKj?2P3#KRr&SUv)T-4OfM=6qRfy3*o zkO6rYe$61wp!IZLxvZ*yeDamYo&e|&2Zw#sw^Ip?r|Q4fOfW1ye)r(U^T*xY2tXod zzL57>!5QKw`&WYvjKAiTTyVw)i?Sz`z^1NAj6{xzStoGBh>NK`ha8e&U1+zzs$n+k z8qH6RVHv{iKo`jsK!dZ>@Z)31C)am=p1ckv+iwK!gokLJnBkue;Xd3`rT_L_&>i+3 z-l@?JU}g=t(vsrW0CddXw(rzeW^)5mVWo<<9_!lT_3K~1tgG(VumAs#O&okoc`~Cv?cMr9NrsGzYA^yB%QsNpLfp4 z>4~4z06ubI;RQc1@3v9Jnw6fO-w_jj=cKz$TT3%7gIzwcUcl4jT zZFXzMv@GEQ%aG(Uzs?TQT*j_fG8L%g<_KV&Nnx3N8X)Tx0eqrtj5rq%ol5h*h}ZBJ zG(w_DD3KqD+Z<$*oSl}Ea-86zt6MNLuTJ`(WT9koj&I4->#=)hnx0@Q6O&Eb@SdsMT%+f4N{2_U(5MjO`WkW2*dx!uT z^J2*#pf%&J&kj7fKHt2fSXdZ=h>S(7i;Llwy$AhN?GyLiUdd@}Pk;#gGS?wM6%=C0 zeq`79Vmxx{s~n-yq@G(s)^?OoIN`qvI!PyJ4!3&1QJRO{LhUJle>vU13W9T`6PymZUiTCNmrLi0hn)5$kj6Wy^ikGbV@lJ$o03;6k;V`)^6o=A6V6 zC@}H?#!P&Qv~_)!@4NpP|D+;#e?F+X^1$b2*-_+lA^l9`l^MiMGxl3`X4at~N55PtQ?+%9p ztw-4InWnAgHy{1Eqv1@BmOFM%1261tVHRBK!=0bF==_Ne*pU77H;0o;BnbXP7ATFh zX=?BV#MlW*TSR~1uTK*YW;FjTLLGj#!Nt$Ndi83M`_MS$R~~eT6m8-IZLLTr6QJJl zyqo?DP=uqF2L7Cvv$u`!oo(i?vfwX==S+{0q74) zL{+cR$=}rI_N?}=4HlnlnD+Nr&S- zdrGoBDWr%t@Xolqn(#x@TRGp-Gj}`cuA`G#Z}RWrfVuF^k>Ya@1nP<7 z)dnJ>)QY0vsCkZxX-zZh|FO|N&m?ick3-00-6v{q$g$PU6!D)|zDCXzjveA@{UEChh4F7|w9+l|V|9 zwxkhMLY^)(-q-0eZ#XbbEDov_U zP!b77A2H(7d|-3g`36NvBRsH~I%cLf1iDxsVp*6`JdUO=AAFnQv zFcwE()GyCE_nB8MP=A<7Ldvb7C7{Nzs$M-@>$P1XpzP3X{AJN@#L{{zqml5^KN3F- zr3sG`Mv_pXT!Q>Oz%BSHJF&go8NVVTbEFd-T6->P`|){2wUzuaS#dUX6_y097Or@W zG|U63f)ds?Kd!3Mz4ynUoZm1umWuv9TlUHQ)qhm4y*l5uUp18GKL>hsz^$eyYgK*! zNi3xckiX&-!?EQ(WzR1S=vSs>XFYm_k%GQzr6F}j@vD`pD&rSpYHPmeZhzF380%u| zm&Oifj`+C{S7=W~{I&VdCeGrFu%QF9Y0eD8?KMZV5jLR=6-qss^PXEE^aL35HQV+D zKrNvu1ANR|e1?&e?~SoP6oqQHL+%p{w7rLuQC|V>@v=#N5f6% z>`-Kb!Zphwj2uvvWK(2xf396dHjo2h7lf>K3Vi>?LL$k-9YNqvAU0P;r;8WT5uey4}G%wrLg zi);E5s3qj(z?y;;MoNkHUUS&<7OBX^mbve5}9d|M=v5aKhY29XK#N60_wSW(;|@we2A)2 zW`>l3U8C10au)F6YdAm;{T=~P75Bdo5?~@1<@`vi9U-F3OGel(cRyD-o575Y zYFxZbFIZNR*rR>!s&hgzpO$Y-7iwdY_X%3f?cM(IOJm&m9>$RKb6fN09=Di4YEOo| zLxd-c8jc=N z_R`~fG=PKi)(Q_VGW1to-m&5b=Yhr);9$hVj!2AgPcTv*NG#K!9+8IRhX@xRqUC34 zs}Ja-9q)lB8)yT+0-uo{ECiS3A$^yl`g6GnSzZPon(pqR8{iAr-i!wf&}&z1{`&a< z&xXm2Dr#_xUY?9*s@j+#uNnFjq?jr`wN*8t3c0{W|9;UY4r!&`6V z zdJ1snAwp5+>tc{falKGhcu0FWw@u!2i4-5%j5aFUeF3ra~gpP^(y=#tT)pH`b0(8pdB2nV2 znozF(G|`>#G7C_vUfXTNZ!n6^5HNEL-yv5!C`6GrDWr&_D>~NY(<_cLaDwTB=1IZA znQ}6HWYN(M*csdAHvvb@~@M2RIS#zM@qpEJi@#HoQys-pPn~2*{%mSGNPiVBX)ru)u zVRC_Dka{%43R)DL2!1W}Dps6*sH5JC#9#x(DC2&G^BX-@!a27r~s)}B-dukH&irdhk-v*Wi#uQ=gXLH%QBTc3nz8EdQ&F2 zE45HOhdB*Qy2i^pv(4K3GsZ9!98<}$TOW!{UF5ApQZ}^Dy0$nu|TyUM*$9!jlRFlbv(mlp3XLRCPJE`fd2ig$D6b^vUK%upq{bK zUwc5so`}?D*1qzI%LOch+}8#&?X8k@UBbJlmcRJ{`dR9YJFyhmt1rQ68;Ua61__*a zRFkD|%wTev!k|FCdKRJ51H|BD`sDVG!QLzA+XYAds0{5fR09g)Bi9Qqj_rwg^uDMv zjcZT;>rAuhb+qw6IHnWpv%2_y3N8X-QJ^CYj)<&|`rz*}3WLW{o<-@1GZ}<#e)Y)J z;mke!G1p^nDlAskg@IENHW2+p5amts%I+c`+XY04lH7`{kb7#y;F~c@Y8MGy$II`LYNKnBm`EDEKxjAp)mJ zVNbwB|4OdJwEHa*qbPXa_{Yjt2Edg=n+te}*7c zDlKZsJ zlknw9T+Kv#18IAYOWpFDkA38kBXrWZ{5u=TeMuY0dwNmKk}~9c=;k;HCZnC&QFm|md8#{-9QUnt{>d&;bt&_&<)a{pC5o~%s& zEXSC18;1&M-k5*{|(x{~{%r z0D%HRG@hpYGSE%=FfjxaQVWH(ARFdF|13HVM^?ke6Rf4=+p4uO}n?Fe)oxd~gx@AbokF z9r&b%ySL$|VAJp=y5bg~5+#2$%ZCY0n|FC|GPwHfa%Zj_@Uz^sas+SZ93?9=ms3v@ zQWLS{qi2AAv_Q1pkH)o%_RF^=h6A4U?LclhVRtiOMZK{)#amNC${Uk!2IsCFUu?Z- zLci{JGi8Yy_W57%p|iiyv!B&=WQB-~sRrHMR?gpYyJDxKw0Tb`t?Vl>{Op`=xrAKP zZ(F%4D{O6yCrygLo^h5VaXh2?G?lp0;Vra}d$9Vqs)|(V;$AD&FL7G%<&)YfGDGE7 z*V8r|s@0%1Qi1HCzIpc2q3r@OS!DkL2dCpF* zyZ6KsXJaJYaZ!<7SVH$E$wJcia*^dk#r*Q+AMU7mAIn1ptStqAPwUHIf801=wn#K2 zuk|v8_t%b?jo%&0k)<_vu6KpBUj%&eO!T|~n73Zv4sz_9WDGZI^m3?nQQ99J0eoab z><^4kudm5E`1NNualcMkdoJAu^!EF6P=|mO55VrR8&7zqC_n{&cwPBC|LEtw+rT>5 zym#TiPS^wMi!45NB7eJJ1U6&<*Nx-rLs;wA|IUtrUqovl@0`o82CE*S*Z-EEchL8Z J|Nilp{|f}=I_3ZX literal 0 HcmV?d00001 diff --git a/assets/level-structure.png b/assets/level-structure.png new file mode 100644 index 0000000000000000000000000000000000000000..bce632b3f2939b98fd9ccfc42169deb4f65417f7 GIT binary patch literal 22220 zcmd43by!sG-u6#}(jh}5pdu23ba#q$Ne&H4Nk|WkfV8x9gD5S{5F*_n9ny_7Gt|4# zd+&Yk{oMESJip`j$NT=_!ND-IT(hq4x~}v3oac9iswm0g-le#UgoK1EFDIpjgoJF4 zgoL7ng#x^S{{z_(_<`)CCM$tdGC;Kn{DNjFt|*R#R33$WX^am1e&@BEjuR3R-q+iI z$es3uW=Kd{#`03)8g7QWO|~}FoptLk(ctfu;fpL~A%0h7w5K`33}}>=kDe{fmUDJL zh{W_OlOZi5-u;BBwety#a+m(pMCx$|-$dfOM#Hz&Tn9}GM5lR=YD0;XNn$-5E?uYv zpLSM|^uAjXq@ZpN&3~YV)7|ib#pOm*`Ayo#tu2x9;NbPoH=!olJbcPqjy;^9`mfDD-Xh7ud`pklu@1pT`y@$o+)paLNs$s`f%3v6YMuXDOSp$81;i8Vd z?>W!ZLqBsRyMiWiXqRV4m z3q}G3M4j)Q%A#gx|7P=m#gUI;FG-tmOPho>DumDHb^`miJ8s0Bimz#t=E9$J&Lfv* zMkX``_w$s!6>g8v^GsRCwi#+a&$^jZBb`KnOeTk%qi|bhz`-2M@bRgeiQNrvTNCvK z?~ff`@&dLWxI@IGB0)&yj@uY;g@*p6njc^6X&eOv;*E!Tkk&^%##Bi9Ch2{QHU$ab zl83ys>HQ5)?PjMZB_O%>CE(fQkQ4N1oo-C+@0JjImvFBNy>aI;(5m?OQpe7kOW&*e z*rgqFE6QaJ*Sk!;8fVgBdi@JoUA;}XcC$}x(++|Ji5;NEmJe_04t!=J5l&R$smovA zz|Cglezq}bDOsxsS%GUK+Dlj8KW!GsEuiVASnvDhWAsBXy1Cpxh1arD?&U#CgbNfBTf=&78y4;%Lg^1`_+%m!VJZAQd_ION|?`?+2>r)S07do`QF2 zdUQMNC8rfCHy$m_yGXv6uAGv6?-ILh@}54T|BHVLlMg1*ykz*ZmB)eRx@&uZlr_hn zp^(~{Bl{cC#FCPXn{h#(hO37M=+6&muP=xvr61yDi4Wzi>1DocS3EOmaoV2xP7X1; zn3)i^uFY@B%6wYG1C2?Uc)k3+|FbD9x?eXS|M9{{goFjHi%DCAo^mzbg+C>5`!)V? z`;}bCN?^$c!0rd;Noh`-vl7p|M&>rKB@plW z5lbU^3Y?sr+9mo=FYa`;(A@`P+Z6IbvFM;J7EomM=T;-}$k=FxkEfr&O-z@khErT< z1Zv71>|O=4C7}jJ;|=@4-sEr&f}b!+dX-mI zIorCQY|LT1N$Y%XZWsau9#c|#&QDo!b#!!CSX%abKV1rokB?7;`JA@MMlRhmXSfrb z0GpiT^Ren&zv&i*qf`%@Quf0`h;KaXvZrUAM@7b^@TjL5n_Q>_O0E0k?I@r;d#ksMmjwRKmRfcuqD&xVyyjOrft9UHX9Dc84m92}@-EO(K>auJL% zpOUE_2Ci)OAB7d~r)ONH_QW}Ot(>gW3Pl`lebHPF4JU=Epkm(JwGiD3vw8gkhn9+a zWxDv;+_!N8y7mekh(h zcO~%Ft1I+ur-LZ%M)^Ee%-f}tIm+w!vDd+|L33Jzz40N!eJu0(*)j{4ag8HTB=yg0pvXqZ zKd)01I`;(M{Cv9%jM0-LIgsn)RHzqR$+qgKy**{2vDo?&H!=rpB_AYNu5cEY{Jiol^vIsDew z_q^;qwfR`){7z>XY{TYyK#HKx3H!-=ULGYyLDkQ$XD;oacr3esXm1PkKjwew5U@ac zbLA6C%Kg3wUetR5VViZt%g5++-)v6dL2smThNC6wSbWd3UtJGaRSyggr|Bp)^| zo~t>pY+KZiICL83+$r_bR*?na<%E&7LhXpE{J3+|qeXI2VMrmTQ&UraIuiIuk!?Ty zTZD`PK~+*{(qhy__7wFyKZ-^y?MQaiU@uhYSU9K;tIZzY(elVf0yrIVhz1I&m7q^S z|EH;H@QxMzjC6p7TyddG+z<51>gR7D=Pr*=f{=WE@R;@8zTXydKZz%~S)Ie$16IgE z%S3)uY!ulq`ymIPdJ86{ff~B&g<8{~B0^g}Nrz7dm9BvfyIc?4ugu@Zexo5eAm={M z1Wb@e61;oZCsyhdl;Cs167DRS#|-IG6^a0(Oej^^4sWnp=PA`L)X)HJWxNTC{ZOkGrkQ!Fk0VZgs6|DCZucUtI~5U za*n^RM$~<5i>n8@);QJRq?6caI}UbOw4WqWr8m)tqQ_#GNFSm|yOu{g2ZwZPJ(n5* zue)v{_py<=yXgnUc)D%L-?~&&LRV}cn~#5HR`<+PG=ULS5h!SvvK}m#FapT2(6(iq zAzWf({E!Ec{q}ebD5-WW%PX`_nSKFR`E^T^i=>|KK|L%v5aSjFL-3qqPqo9~CR-kF zVpo0Fh$mOV!_z%Y4a&Jm(fDU8#uLlMB!PnY17C>5$3Tyg8sgnaWaX(hrjC?UJAk8B zO}>c#nZQ>S#rn>RF5;o@Aoa9+>_UfV0sl&T)UJ9Fw4yKPm22>`(|`)ID{*nF^d5Qj zSJLWA<>2~6_qegZbU4cW=^BA;MlrMx;Gg<7sG;2!wFpVnFDcNknDmbY3WNh*`l?F0 zyf@A9(9h{1lZ{J#qJXu6&-QLfeF78?uISK9yFHE}BAcj8j8aGR&GEDIWP%eDLCTwk zcs5l?6`!SxMuu1nckH>W`$G9n6s)hlM|v7R1vz7eRk@YL`F);fh8{*jbCN}L)po*Y z%xE5d?Ke8lzWS8mftDq6p@a6!CMt3-PRF-4vkTNR0NObkxa1#xPNRo0D0oFNI>Owk zg#Mc6f+XjmfL#u3!*ESCPM(^K4C4G`+cK1*XV_s+k*hkgoiXhp+ABG!v%BMxA?DHP z5Z`{wE1Mb6mLO!1y6-w6Yn>sbNhJklw5F=o({e7+VrCSpBgzbK8vu>P8E<2P9P1V# zwlg^&!DgqeeL1k)G0%qpmKE#bllYr4tBoJlPt*x;R_- zC~oZTK6OkNNW!@Snt7o#@&>v{(8&z6|-I`-rI0TNh$vb}(7$gQo7a&xlVY$|-tuI5t{(gCe_sc@W77QtoATQRKaG z0&j%8^IE_-Ib;Ik&{2}HQWlO<7#F?ZURcqoob6wkNkOIH6;b6u-bp1ubEVO5Yw!R& zCcYOj{^=DHd4ir#v%q)$#%4mRG)wLLbj*M~#HNO7GLmAH6{K*Vj8)IScB>HV#ZcRS zDC@*cI#*8qRe-ND$;NQv@=ZVG>qra61G_#v&Bh}sJG~FKJg!We#9K>YV5;*S)ZqPz zZ1xuFnKK^?_%w&%H-`KaVj2qw1LUn1`FSlZ2eO6G5}GfuM7g7Gz!G^d-u~>cnVKV``w+pJkMY`(dIpM*w%}e{SF7cS9#wN38@5?1z?)wPU8=HGCqv(`Hd~E{` zh4NiszWr!*-*H_o9suXf@pT6qTR+;z+c-mi@gCweR2372`xu|2fKxDcurLUWBtzQT z95v}f-fBH(hKv{my+T^*OP6a_1woMHkh{#VUB|f$bqd|zRT%*N!gB{I zjwK)TPr>|$ocXL{X<;P6bu5kjK)s`RDAU)#$u}DD^b%IrI1gUb;#A&O@dFoqpAut&^a z9XD1ETZKnN^bZVtxZ+AkOmy9!Z&D19=)SqbdMusrQX)QH@Oavmw?8mLu(E85uCh>Q z!KO2d$@AM7Z0d=ljSgveHm4hFo?sC_6(p{_y80BT1Mh<{NP97B<_Og((_s)OR*7Sv zqMg%@vzy+bulI@x`IGvz##XUicRlK4WDI8}=tzZtR-`$^m%KBBtAs({wQJbOWa(|{ zy!7iWl|ERFxg-Gwt0HOhh(&*k3}?#6&d<+xioa!*d}I;~C~;Vo==yn)jjKZ?>V2sI zK@g3VK`&ff#prs(fNc=+hq|8Ek{Y`ftvW<`^{Kd$x?D~uf_q_AFmu>XygOuVVzjAI z1jgr3)sr(28D9(fjfPV%YmLza)sPV=-jSq#Hm8oDpT=;*(T>#Ul}#ooSMRq*JJOV> z++)^^D7Rpi+GkbiW6>1C`_J1?q>a#295o~c!q+Qb+^Zs~kVLQJ4Euoow6sS@w}0bk zfE21Z#ibrVai;S6twqI6S8c!;F@nB*Kvs->8EI9P*dZq_8rdHff`o0fh?FhxSo8x5 zR2+i@k&QV7G86j`Nqjr?-$VElyuS-o_2a&|dG`p|i;W>6=8jIxhT+~1h zXU>POl8{loMvr^S;&1R7SxM@eBq@zOl{}BF$J+so)CxYnIM%P{%XO z{#njPwgsb^I>`|veN(zC&8n+%AOllOmOz{IA7|1Jb$t8b+2nA4(9CBqRqn(By#ZIk z+XF=df24#9hOwu274Bebx~wxiY0MjW&SerWu_we2S*d?<`>tPK3GMFdP7=E*H+o>@ z9V_AR7kYn;0#jpC_AQr(iR`qYrRI(ff#@E>eDC}BpGbR3u}aGMNI(MP>K6erG#I zC%?ZDMv&u2mM<-v1@1!ns~yen;mhVOP;L&L$hbaE+(G@=0`hI%YbSHiNkmTsV1e;Gh~Nh|1jZO<1t z5lrK;vNdl0@@0(qT16^V!=tjR*;Mp+;}S*7)e9K>B$mf%BEplV(%eV#8S=wPJ=_U0y-md#jC|B_&}`FgwH zxjL=r+nhRnr+DuYMNbS(yS4qkS0?68x(6f!Yb$dxSlUd|rV|xh%J=HXdHa=rtU5b! z^!+*{pX*)}zUZZoiPu4}Xk&yyV|F;jC>LrB!$Qp?j!LY!99CkNHB9t18*#(b7yO2y zM`kyaB&8e)p~Z!M^R`>}6J*MC9(~1ys)od)_xK=2GhB8l+;_^G-H-a63R{_b3LR+8 zCTmBQ-KpISRm|)7P2&-0{rV~5A^zFtRjY0f2V#%Vd+ujUR2Cvb#aV8zBG?WFhg=u? zob=<1)E1RImg-qIkbaV=*VRG-6B8JiD z@+(jMgr4K}@MwjhOs3bt8%LY9u5ljn66aF)Cw9LEj$s1zV?BUNi*ZGJ+%tfeKlg=| zS6~GG9+KsVg_{$IsQC>PZ3Y+@$Z&Ji3q%iy?0uJW_s)9R5mTkI93Fx`zF0ePZUK^+ zdUGW$HV8E?klS0Kk9yAQUqBhS^7m8Fzg9bv3SfTc$$o}8ShgA8wG@o%O?FN$vt7Os}Sk22EEA(k%b*QNGGVh2AtMbE<+ za_o7$@gXb*1`-ORoi{)7b#ZzYv|_UYU?IK^?R(7^G~w(HhpNf0ScA{dO;)!uC*~5kI?n<0YKK!?w zBQ1)_bhQd{LmZRb5w}b&1c}>b;fDNtX(S%Mog|^*dd(*tx@unz;AnctBs`>~uVQ(l zLf$Ql%6Ob0zskW=X9vDo$lpAmX*`h8o5SStdN60&YLj3D<5(IkM63hUY=3E}*XE0i z{`MZ3zEOyuAI4iseux>WAB&xBba6$7U)zIfN%Pa(ue|=7d0Q!WY}#_9&FO0CqsyOr zlS#NIBuG`>PMR%cut=AV=aR<$`Qw%pEj(c-X3fhS(M;6OxJ`QHbKP*m~rr7N|^p2Hcfx*uhILZ(`0k7ev66N%UooEP_hN(`?d+6?4Xm7KW~^Xj-5tS#^~3N9}#n(t}OtWUk*6XI}m zYrS!QVQOb$b}3~#+DtxUl}bjNoMdftDwMmhG3Ax}k>eA1K%@@ayir#iP0tfn=KLUC zVWSJq>m>RMN$pc7iwNyr09m;^P*YFQc^Ni*gr56GJ0$vaeAG==kq# zGGn~_TM$clB%QFuaT?f$-{_Ua9veZ$eaEe`J$abzaYIrhdd*6DD?dekQ}=YS$7rpI ziP8i};F+cy=x>3UIO4826-Ff4p}l0zJ^$Azw)EXQWI+VVU;AWu*FZS%TU3cC-3<(|uFxV1xKmL8{O$P5lBa zJFfZ-pf38Pp8xEG4)4I&KaVX+*E&i$r4n%s$@aUohTp$Dz3<91_$ap&D6Wq^C@m<> zDSY@kAz)I`7pe27RImJX6RL`ShP~Z<84)vcG508|Nm@8qn=}NMk=&Le531*vVbH z%C_}17&zNt?1U=U;kCj%dE6tj>ye!jN~sLjC-JjYrdyxkuR{6X#63bt2v5gl1t0j+ z{?H|U709t9|A5(2RulEgMuPT)=9P&Cy(}*&UP?lpC&N`s%lXo7?EckGq&5wFL-l-V zv%y=?b)jwL>=k(^fcrb$}wrdrwjOjjfCxcwxBQIvQQMjOV!ET zqn6xxyvdZrgd@%|zymFRU3<}dL2qDW1VHXfR`>dYcNImulx8j}FAay*HBN&1M0VN( zamY~t;w^{&7t3nA-x>OmE71ryFwyia$jkAPlR*Fz#TPDkXY-Np&u>dhCf<4EuT<<^ zOn%AuYCMF*O}=0ShS||%5D~SGW}eT(Zy9nitOb~`q~zo~S`|6r6k{n@WZyxz=#x>P z@oP}JK@KD6;aBlLp+}AWg{h3%urCeGuTx<6ZAVkqLEi}@WF+sdRv;~fZNUXoumw_c ziHxg@fwhizzSdJYfkcv9uv(dw2qHSAZ!wS1Fb<%9=4I@k4Cj!Cj8y)aSEpU3d21)wwFQELFg_!lyk;XL&wqp;38Kppm12T$6tF%QI8vzMbXXX&|__q3GH(g z^cWOlg}xG|J%GXe&^V9G-(Q;=oLY^7`V!^q z+ipQ2T2sc6t6lm>So9Po@UcC1BVg4!CiuM{{FfL5oRp%XUyz!oFz46LQ!>Y2g`^{T z=VcT?_|7W5>*4m(7owjr!_);LSx{TQ$K;tH3LuwrASP2Iui@2C85IeAY=DWA^`hsK zBZEy5m|)(ieB5d-3#cApOQ6F40j@Rr5ljr&yus3h!pA|U`Hk04JkXE%}V2yH+Ljb>1}gPQc;!_iS>Oyz-e^rXq1>;g-3x? zbNSKurM_O{860Fk|7RS(V;e?~O)zLV6D-MRokD)zx@5Ul4Yx|naLKq0=>mEJvatuibo z7XQSsUaDRipjmi`Ri4hvb%rgI4+-*O3Kqx$NtH#ir2pY^u;1Aj;_QQ2 z32ql;q6&I?Gtqa^$js+DqNMyxuuMf9Sh|f>%DeDPV7pw8c+DrLqG`YecN6d}9w9%Z zw(NpFeXJw8oB4IeK5`?`N5L74A;e^Qo&oW_9z=wS1HJ-X#7I6k2`#S5qzg)CWHJ;C zy#4Z_`KfB?uet%QTEbWx;IT!D{fFfi_NvQC-QD@sN30^;W9g8M`#s~u+GqO_w_v}u|$`K)xQ4G zW?M6dp+3w0hLG~^q_pX1#4iZp%j9gAmg_~HX$$E>JcVD~t6tOPK6BMcyC7WGiiN#@ z_|CxD@|3rAZ9&YH4u1;gOH$M=f@oura(bbo!;}#vB^p=J$MRzR$*OmU!QjY@r;-I9R91Oq<8!ie{Q`5@I zN{eQs3D9e_+_L)mch4G4Lg>sD;OA{u33$<^isYsWLP>j_&xM~nw8rC+4;-%Wdlj3e z^@wo9+}YW(dWLE;B@D5<`asMTXVmLn+GPi&>%K#_uKv+?g#3kFRihouFEhoR*TO+P zuV-$0Zt<~8zaArRtO14IQF>)h{{ywps8qn!L|MY_GFhGOS*av;6}lczp}ij4jfEtO z3J2$$iMS7jts6#bdwqJ&dz95Dl23$jPSOF(%gZC?KSb7yKm5!#h!_5dz`Ot4X6k;} zJJXBT8udvQ0!aGkD>DjBMyMULmhf%|e4T04SYa0H3`c{S28CftD~FO1p@N4#8la{9 zMvduqY3Ujbr}Intb_5DTSf}?DL(-C`sQco1GCVo4Al>^4#OJXd<(gXOrfXPkUC*DP zRB0~~6I0rmDT2j5eqYOOPAu&^7B;2Gp5Y$|8gE2q&Jf=mk8WviIdNOuJYXIte(v9*0VzhM-5di6Qu|1)95@fGDhaS-7 z((AWd_;fBc>cf;a_g1kctX6+1L$|=QYPMdSt-jF$o>TYX@fPz$p!1JzzXt?y+GkdX zH|8FzFay>Fu{klw!_&mID2zn5gCMT11+{*1Z%EbI1@^%=5T4hm-`6v1RkFp%-3!r5 zFI)1`CBjkGGgac1umSJIpTipNVwwuX7z4pG@b=E0m$>g_uMgbC8?QRbi!!z0-Qy+i z>Y+J;&$)P%<6j$uZFC4bUE;QziOW3Qnu{%VGk6!-lV`f$Sk#WIp_r@73=mgN!UKQb z)^8J!ns*|>?D=KO*x}sYzkh#T!-DhkM?$}M*YWzuSnvAe=c){)ePUYXKUg-Cysz~4#4rLhD@t0$}ed40;cYzI0?fsj}Hj%m(=Hnj- ze;nlLM7qZQV0%+Ke5ACt?~Hl6mhJ7ye`kpLE6F`+#!|g&C8%7D zcxm6qHKHjW>U~k2b)j)5J{XoCdOIMVn2=5PDlb=RgKapXUPyyxL{{tVTobc-hnDp7 z+?5or3)$Ki+=~MJ74J6t>!F;MeB#r{6A6WvE~ia#k>UNi8-Nq>t5U7e_IA`9NHsU< zZu*}p`A+R9!D`|x0mjNr3LYjfA3m!<`hI&Za&mDQRB|L!FP=0M`)_B1>1Ux0x-h>} zN)g0A%QIw-h=R*Jn3bcp*G`ach5*2|M%M$Mk`V;!ksULfJ_Yqjk7^%!I`VH7w_d7V zCpT5%3UGVOCx^I+8(k|E@y_X`>PstCmOE9Qyh#}imP>MvkVZr3dekVu1$W6`$G12? zdpgzdRNz&*Sc>TAjpSRT+j0S<<@Vd^P;z=rlk+ixAlTyo;027g_POc&CXbBWPph%fIz8myV#Hf@56H&hCzWrAqm)wNyWU%D=H30 z&IW1DyHp#qZ65pp)Phk=_BM*1O989rnleUj%kI`on|X?Lm(kY)r8ljHY{OOp&iG(H zpUB^#k3eY)=HHpNnPyh*t}ywVtIWscT}0-xd}uT56`={2Swc|R>!T2JBZ0sC?FfL#uq>z7CSk!ZTAM+{%PP+^X-Epzo~q!Cnyq(yTlP)HlsE4*fBc zH3pI}wevrU5w}c&`r)51(Uv~<@)|&(yyCmL{MN19mveo%+Dng3aE{XTycOet$&2+l z8xJXE+BUL*HM|m(S-6~CJt4CK*cI&uPi5C?pHldH9bddPo2{6jdNAR*n2F#*%Pw2o zpe&0DhAcU1Kl_D=t?h0>O*4H(8O-gbEw7-u*%vWBzV_+CKK6YEA5Nw&8p{w_js6W0 z;(70E*{br;2XnEx>zmG=-R_N&b-nlK7S>&QF^`U`UK%wn%GQ1l6dHDZ_&&Ze=c8Zs zd%1SutV@Jszt-&z^_naYhe~oUtVS7jRw-!czLF#RBdlN_u{#UJj-40;XtOqjW!9aB zRp`@pspd<)Y|p2Q*8M+4o00aL>zsFB>X`O?k=k*298sIc%FI4;VFiPiL8{Q$P4vYG#$zdDxYUa2b*65!Sh9K-lE`>$?| z7hjz1H){s89UfL}O3Ddm{$2_qsrXT3XB)N!F@2nn0kyaKIAHAEkF#%i3W*jL7DN-3 z{`3%iKoVBZ*aa}zCDn)EqVHsvZ_3Ewl6gZ~NCaw>)L=M6)!^eNQELLnYr0=r`%pi? zR35%FL^1u|X!9N!iW>YL@ThL{f5xQQ{GUF30>)-m)0k9;pY)ScyrXE2{ft$1&DrcJ zCE3-WIFCHwT;($7bb%BU6jlcXk7$Rog($9~bU!a(F07=uqi%+M&XgGxq>Sj8oSfX* z`7{M?IYFmFX8Kf8l1)@fW+FGGCIWlioQJw%t==(7grD!}U^tk7tC6x(W%o){{<6q5 z1=Z6@wYe!3Z6nf{v@|<*!EJd@SV<$ydR|xb+L2aLY)Xaa`|Ibq z0%)(j2vPnwX@i419CLl^;EV8{PV9{8$r>Q@NG~h0_fZWtzB=VM9GE!aL>wymhHpv^ z=~vd^=TUa?2C&?*6kA2hu>4x=MtIo;1Dc|J-s`8F&cD79d6RgVp-=n27JA9}d2qKo zh@2u3cMDO5LgoI^i3&e~&xE8rULTq)>QcIEcrmGRBkG$DV~ZV0O^N*G4ZhTtjMXX; z9zug=^HO|Z!l4r#qJs&K)Tr$Mc@S@g{0Yo{a^BHeYT8ikfg9geQ?TbRZq~5c;xd+G zcWtQ@U8)7wb$WTUJX7K4qr1r6OWQv#)%sW%d$TPTwHyC&>E7`1c8_xlwBhbT%VY*P zct`%(_30DX-69h!0PjCZYF?u{b;eI<@0JL4MN_VgxzrVP9z*Wp?NxILi^}G zs=P9@2&y6RV}FupO|oHyQ|_gg>1jBU7E(l4PS2ZWU>dQVY9jGh~Jg2=3Iv$A$m z!9=unQ#+P&K4OYZSF)IT$>|f7K>pXIbTulYGGVQdSSaSyPGKx%k5#F}q;_hOe;QG* zd-r2%*h*PB6xB8nrkpGA9Pn~l(BbQ#({6ObEc^*jAdc|Ai#qfo%0J7w@G;!KU;me&}r(=B%gnEo|cL^h;lzIud0HzGZsyP6;@lJz-QRBO+mFj;-Ykyne;I z8#6ZTyBtRFLd**|FCu(H=X+^XXVLxR0Xv#Z6K@(C;$VJLlAozygZ3YBYe`I zz|VUY2D^k&T`Bx48q4h<(aphrMnE<|p0ou4Xh3v$dm^CTq9;|Deyg{(k-sXyo%G@S zm*h4v!_Q7#SCuE%e_c;bYtl4OJD}bedq#>^lu7-2i!L12RA3Z+Q`u+i1bg zqp<iS*?2f*Wz`#Rx*D;c8+LXj%0&r$|ZEX^S^DV#<)5yxi7K5_; zfyRZG5jp`zQ+@lk#~gwE*IJgw9{YQ9rjs4(^G`KXjB*@Iyv$5Wo=%F=(i>O+e2bHl z_>;UWId>uN#nV3<8J$o<+l6Xp054&GocNbkLqz|*R=e8A?)`^G!~O3yn#vM(L$*7B zn<%#imaHsUkLW5}_M0k3eaPk!rBjkyn4P|+{wZ44m?~Z(g&21br z^-e=cSvh>8!{gLApL4X%;hpf4h!fQp-BlSLxU7oqW{5aXAfUVT)mM0*#2iS|g#23$b6y0ze{RQe@lNq#Q?P>Z0jTU3E^-q6+ zuRo%^i(3urmZE1P_@x)C2Qd>FK0mp7iYK&yl*L`BpF^PHkcsJhch(XfZO@p>i{#{Q zwEDFNFS~=w3Ao;>WMY8Z;u$_jzR&a%JZ5LWY8LKFV|Ap=#luMlKS9R6v|AD~1I(jK zBL|Ner@}iH567Wdk zzrBH@yWRBXnVzKyxP5hK%w~yqf1bg%BtrW#1xOm-iM(dK2YBo#x6;e$_@B}%-K1h) zaFAm<|2E`l%=joUR~;MJSP1~ppMOt~eWV*yTpcbdqR-{|J#A~38qPKQ+*UY(4K6m&!CT;WB z2o#~nEAgu3=LJgsP~DsNhvvT3&@50W*F+t!(~pe|3wd)BwN&pGw$vDBntUGergn?U zW*H~Y;fvXCe43kG2II;9MUrJ_nCc{dckZk!l1$NK9icw9wPfD&a2=#RkiKm0eUz(i zxpjC+Zvfm5fFJq9XFmA`s|SdG_I7*t_gsx*O41>+7Yc`u24r%c^6>Bo3R<6HzlI{q zirps?1i@1^W6 z*6yM){v&$@Ej$DUFWCHj@E}+MP)ZeUI}@V*Ot-;_$c*F+clWK$2_L^4*K4=V<>=g{o{Sv^td;gkzY<`4m-`A+nTakm`!5QuR?5S5Elpns2~AAcnts_}ZLi z*!HuCZIUc^U9PUa?Tot11n0`!XZvfa^4b3j4mX(ni^Fl>K<@>r$U=X}=FhPBaA^}u zLo-sM*@XDTIMEs#>t#nvnMaw=c1IXhSv3=D!FTP8raoyt#Ij2#xs@0DXQ022joui4 zG5WOKfh4jNJl09e8X>zAEP^O^>HP}=cX#tvqapGB(r8kjMnUKoYe}v+wjN1xU)KIR zRi;BW`J?=b%9a@-IQ4ToHcy;=u;m5*5s4_0^GK5zJG0wcrK=s zGXF^JGe3|9dM$np4}v=SX>WWzK_|B|)3S1rL;iPV_H8J$gUM?o83{U!@;f<-#>PX5 z(#Q2AB{$mfSDVY9GJU^p{=yD{BiHv7l90d?-Sc*SH|r6zbUn|%@FX?jlsuMsU}MO zrjJE`rfnJL{!>p`0GiYd$gzLkG^R<}*&8cg)0)*6QtDId6BK3dgL`6ecbv<2)l0TrT!&dxd>Up$Gr)|F3XV_S3|!8ad{YMr@viyv+S{q>! ziztAI#^+$>68u~kK#>gu`v6g_sInY48#_R>flY*|hQvW$j_w!M{}zce;v?B5Pzixm zzJHNKd9@X`2oXR7LMUot)X;x#yVK=Zxot!dGbEiln*j}`dve0)`-V1F~auBB7J0WkO47HE?9<%xec;*jVmj<3J-WX0?Rk>!FD_? z@>V;-r7*yvu3s;rCC%hK;fL=hi6`bMw`_ z{S4usF*$D1Q*vzpyB@%szW)q!Dab;m&qnx7Z|?gMbMGsB_Lx2gB5ld7A>qcwxp2H+ zEmi+(x>m&c%V-1^m;`#j(oc0?O4)BJKi=p}hr`oKl?+9rI0Pt^&7{13lxkHm;89=d zWRn;ql%2)ank_xIAb*?%7<**eeWj|^c9j>ukVR&2hDZUnw01Qp6m~Wzn$>-;(C+K6Zui;^_ z8A-BNtG$WRr1yWK4*{-0n;~YSAZE8$*0$*pp*>p68M@fT<>Rk;|D9OtC;xk~wgN?- zU4TrMixFH_E51^b7gfy@k)?u*)aZ#M6%;6$DQ|c-{RDP=SAT2gL`hPF;O%@;B?q_jnf(Hb71Hw#vm_KgC6Z9oJlVb zT^-to?tSxROfk82NdIee!}DKsjBXrgHNZ-$C@n4hiUkzk7#@ozl&%tA@F`uc?f)xy z%q%aN1;2HTg4gal=41t=|L|Jav4^R& zw6uEG8HSZVeiF8>U!EPKvn2OiSSsn+-xZc5ZBopArvIO!EO+h53g_;VXBS3~HQtA` z@2qEntKoj}i>t}rTP$9)L(|nrv_gbBJZF)Q^wiTmm>cLxol4GXliiXaIl=ibt zouRKB`t`OMcN>Ne>){ywcBHdpx*td1`sli}eOAbQhSUk7EAu@^sd!OW%>mzOr;hSD zSqm0@bFremrabs#qe*%GXsHggaxX)CR1q4d8Qv{h2B(XJ0@RPu(FV!O5H@b!VaF9? zz9(-T9rR4(O<|&H2N zeLxLl(aT{iT}$PV8?5~Ed1V?tZwv3W)uIB(4SM7}e3{ykEj>Yd=Y!pvy0u8Azl+~H zvw!HZ_MXqbT6B!V^6*aVfJSes*U>y%r#;FgJ;{~rR{un1^w6sV=_BRdswGIxp~Kk@EZ^g{R}H+bxwz~lSq-pf>&+-S-JuN2K9+Ffq_!x z<*>dY$w`E*ME%bdF%F!bVo$ECdX6T?g_283s|(<=&VtN&aSpW~tO89t8K zC)VeL)R&Z)L4WL!4vg{E_IK_?OP?LNeiVg#{_;5yTkYR0&ud&~Y0^9fShVz>{#JSc z0H>3iN7KlY|B*6i?haEDF$2O_iDGB>ag|TkJa43V*7IBa|HaBw*F<`iBR#t;E&|e< zM-9i`NosDv*80z`W0Nts{eEpLlA4L-DBpOlGHS2(={%bJr_z!_SUjb=ewnOwxt-8V z8N-a?N|C*jvp`w{?z&+tzVl}@v+vf-Oqw!c{*A*>W}pqgU9(Gh{n7yvH^&}gKxq3R zp_P=p!QGTiHE~o>dJvJ3A29pYN`LNW;r$wQ0A>bK ze53=qVs2HkAB