Repository: dotnet/eShop Branch: main Commit: 5624ad564d16 Files: 779 Total size: 1.3 MB Directory structure: gitextract_kui8zjym/ ├── .aspire/ │ └── settings.json ├── .config/ │ ├── CredScanSuppressions.json │ ├── configuration.vs.winget │ ├── configuration.vsCode.winget │ └── tsaoptions.json ├── .devcenter/ │ └── catalog/ │ └── definitions/ │ └── imagedefinition.yaml ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── markdownlint-problem-matcher.json │ ├── markdownlint.yml │ ├── playwright.yml │ ├── pr-validation-maui.yml │ └── pr-validation.yml ├── .gitignore ├── .markdownlint.json ├── .markdownlintignore ├── .spectral.yml ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── Directory.Build.props ├── Directory.Build.targets ├── Directory.Packages.props ├── LICENSE ├── README.md ├── build/ │ ├── acr-build/ │ │ └── queue-all.ps1 │ └── multiarch-manifests/ │ └── create-manifests.ps1 ├── ci.yml ├── e2e/ │ ├── AddItemTest.spec.ts │ ├── BrowseItemTest.spec.ts │ ├── RemoveItemTest.spec.ts │ └── login.setup.ts ├── eShop.Web.slnf ├── eShop.slnx ├── es-metadata.yml ├── global.json ├── nuget.config ├── package.json ├── playwright.config.ts ├── src/ │ ├── Basket.API/ │ │ ├── Basket.API.csproj │ │ ├── Extensions/ │ │ │ ├── Extensions.cs │ │ │ └── ServerCallContextIdentityExtensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Grpc/ │ │ │ └── BasketService.cs │ │ ├── IntegrationEvents/ │ │ │ ├── EventHandling/ │ │ │ │ └── OrderStartedIntegrationEventHandler.cs │ │ │ └── Events/ │ │ │ └── OrderStartedIntegrationEvent.cs │ │ ├── Model/ │ │ │ ├── BasketItem.cs │ │ │ └── CustomerBasket.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Proto/ │ │ │ └── basket.proto │ │ ├── Repositories/ │ │ │ ├── IBasketRepository.cs │ │ │ └── RedisBasketRepository.cs │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── Catalog.API/ │ │ ├── Apis/ │ │ │ └── CatalogApi.cs │ │ ├── Catalog.API.csproj │ │ ├── Catalog.API.http │ │ ├── Catalog.API.json │ │ ├── Catalog.API_v2.json │ │ ├── CatalogOptions.cs │ │ ├── Extensions/ │ │ │ ├── Extensions.cs │ │ │ └── HostEnvironmentExtensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Infrastructure/ │ │ │ ├── CatalogContext.cs │ │ │ ├── CatalogContextSeed.cs │ │ │ ├── EntityConfigurations/ │ │ │ │ ├── CatalogBrandEntityTypeConfiguration.cs │ │ │ │ ├── CatalogItemEntityTypeConfiguration.cs │ │ │ │ └── CatalogTypeEntityTypeConfiguration.cs │ │ │ ├── Exceptions/ │ │ │ │ └── CatalogDomainException.cs │ │ │ └── Migrations/ │ │ │ ├── 20231009153249_Initial.Designer.cs │ │ │ ├── 20231009153249_Initial.cs │ │ │ ├── 20231018163051_RemoveHiLoAndIndexCatalogName.Designer.cs │ │ │ ├── 20231018163051_RemoveHiLoAndIndexCatalogName.cs │ │ │ ├── 20231026091140_Outbox.Designer.cs │ │ │ ├── 20231026091140_Outbox.cs │ │ │ └── CatalogContextModelSnapshot.cs │ │ ├── IntegrationEvents/ │ │ │ ├── CatalogIntegrationEventService.cs │ │ │ ├── EventHandling/ │ │ │ │ ├── AnyFutureIntegrationEventHandler.cs.txt │ │ │ │ ├── OrderStatusChangedToAwaitingValidationIntegrationEventHandler.cs │ │ │ │ └── OrderStatusChangedToPaidIntegrationEventHandler.cs │ │ │ ├── Events/ │ │ │ │ ├── ConfirmedOrderStockItem.cs │ │ │ │ ├── OrderStatusChangedToAwaitingValidationIntegrationEvent.cs │ │ │ │ ├── OrderStatusChangedToPaidIntegrationEvent.cs │ │ │ │ ├── OrderStockConfirmedIntegrationEvent.cs │ │ │ │ ├── OrderStockItem.cs │ │ │ │ ├── OrderStockRejectedIntegrationEvent.cs │ │ │ │ └── ProductPriceChangedIntegrationEvent.cs │ │ │ └── ICatalogIntegrationEventService.cs │ │ ├── Model/ │ │ │ ├── CatalogBrand.cs │ │ │ ├── CatalogItem.cs │ │ │ ├── CatalogServices.cs │ │ │ ├── CatalogType.cs │ │ │ ├── PaginatedItems.cs │ │ │ └── PaginationRequest.cs │ │ ├── Program.Testing.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Services/ │ │ │ ├── CatalogAI.cs │ │ │ └── ICatalogAI.cs │ │ ├── Setup/ │ │ │ └── catalog.json │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── ClientApp/ │ │ ├── Animations/ │ │ │ ├── Base/ │ │ │ │ ├── AnimationBase.cs │ │ │ │ └── EasingType.cs │ │ │ ├── FadeToAnimation.cs │ │ │ └── StoryBoard.cs │ │ ├── App.xaml │ │ ├── App.xaml.cs │ │ ├── AppActions.cs │ │ ├── AppShell.xaml │ │ ├── AppShell.xaml.cs │ │ ├── ClientApp.csproj │ │ ├── ClientApp.sln │ │ ├── Controls/ │ │ │ ├── AddBasketButton.xaml │ │ │ ├── AddBasketButton.xaml.cs │ │ │ ├── CustomTabbedPage.cs │ │ │ └── ToggleButton.cs │ │ ├── Converters/ │ │ │ ├── DoesNotHaveCountConverter.cs │ │ │ ├── DoubleConverter.cs │ │ │ ├── FirstValidationErrorConverter.cs │ │ │ ├── HasCountConverter.cs │ │ │ ├── ItemsToHeightConverter.cs │ │ │ ├── WebNavigatedEventArgsConverter.cs │ │ │ └── WebNavigatingEventArgsConverter.cs │ │ ├── Effects/ │ │ │ ├── EntryLineColorEffect.cs │ │ │ └── ThemeEffects.cs │ │ ├── Exceptions/ │ │ │ └── ServiceAuthenticationException.cs │ │ ├── Extensions/ │ │ │ ├── DictionaryExtensions.cs │ │ │ ├── ICommandExtensions.cs │ │ │ └── VisualElementExtensions.cs │ │ ├── GlobalSuppressions.cs │ │ ├── GlobalUsings.cs │ │ ├── Helpers/ │ │ │ ├── EasingHelper.cs │ │ │ └── UriHelper.cs │ │ ├── MauiProgram.cs │ │ ├── Messages/ │ │ │ └── ProductCountChangedMessage.cs │ │ ├── Models/ │ │ │ ├── Basket/ │ │ │ │ ├── BasketItem.cs │ │ │ │ └── CustomerBasket.cs │ │ │ ├── Catalog/ │ │ │ │ ├── CatalogBrand.cs │ │ │ │ ├── CatalogItem.cs │ │ │ │ ├── CatalogRoot.cs │ │ │ │ └── CatalogType.cs │ │ │ ├── Location/ │ │ │ │ ├── GeolocationError.cs │ │ │ │ ├── GeolocationException.cs │ │ │ │ ├── Location.cs │ │ │ │ └── Position.cs │ │ │ ├── Marketing/ │ │ │ │ ├── Campaign.cs │ │ │ │ ├── CampaignItem.cs │ │ │ │ └── CampaignRoot.cs │ │ │ ├── Navigation/ │ │ │ │ └── TabParameter.cs │ │ │ ├── Orders/ │ │ │ │ ├── CancelOrderCommand.cs │ │ │ │ ├── CardType.cs │ │ │ │ ├── Order.cs │ │ │ │ ├── OrderCheckout.cs │ │ │ │ └── OrderItem.cs │ │ │ ├── Permissions/ │ │ │ │ ├── Permission.cs │ │ │ │ └── PermissionStatus.cs │ │ │ ├── Token/ │ │ │ │ └── UserToken.cs │ │ │ └── User/ │ │ │ ├── Address.cs │ │ │ ├── LogoutParameter.cs │ │ │ ├── PaymentInfo.cs │ │ │ └── UserInfo.cs │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── MainActivity.cs │ │ │ │ ├── MainApplication.cs │ │ │ │ ├── Resources/ │ │ │ │ │ ├── values/ │ │ │ │ │ │ └── colors.xml │ │ │ │ │ └── xml/ │ │ │ │ │ └── network_security_config.xml │ │ │ │ └── WebAuthenticationCallbackActivity.cs │ │ │ ├── MacCatalyst/ │ │ │ │ ├── AppDelegate.cs │ │ │ │ ├── Entitlements.Debug.plist │ │ │ │ ├── Entitlements.Release.plist │ │ │ │ ├── Info.plist │ │ │ │ └── Program.cs │ │ │ ├── Windows/ │ │ │ │ ├── App.xaml │ │ │ │ ├── App.xaml.cs │ │ │ │ ├── Package.appxmanifest │ │ │ │ └── app.manifest │ │ │ └── iOS/ │ │ │ ├── AppDelegate.cs │ │ │ ├── Entitlements.plist │ │ │ ├── Info.plist │ │ │ └── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Resources/ │ │ │ ├── Fonts/ │ │ │ │ ├── FontAwesomeRegular.otf │ │ │ │ └── FontAwesomeSolid.otf │ │ │ ├── Raw/ │ │ │ │ └── AboutAssets.txt │ │ │ └── Styles/ │ │ │ ├── Colors.xaml │ │ │ └── Styles.xaml │ │ ├── Services/ │ │ │ ├── AppEnvironment/ │ │ │ │ ├── AppEnvironmentService.cs │ │ │ │ └── IAppEnvironmentService.cs │ │ │ ├── Basket/ │ │ │ │ ├── BasketMockService.cs │ │ │ │ ├── BasketService.cs │ │ │ │ ├── IBasketService.cs │ │ │ │ └── Protos/ │ │ │ │ ├── Basket.cs │ │ │ │ ├── BasketGrpc.cs │ │ │ │ └── basket.proto │ │ │ ├── Catalog/ │ │ │ │ ├── CatalogMockService.cs │ │ │ │ ├── CatalogService.cs │ │ │ │ └── ICatalogService.cs │ │ │ ├── Common/ │ │ │ │ └── Common.cs │ │ │ ├── Dialog/ │ │ │ │ ├── DialogService.cs │ │ │ │ └── IDialogService.cs │ │ │ ├── EShopJsonSerializerContext.cs │ │ │ ├── FixUri/ │ │ │ │ ├── FixUriService.cs │ │ │ │ └── IFixUriService.cs │ │ │ ├── Identity/ │ │ │ │ ├── AuthorizeRequest.cs │ │ │ │ ├── IIdentityService.cs │ │ │ │ ├── IdentityMockService.cs │ │ │ │ └── IdentityService.cs │ │ │ ├── Location/ │ │ │ │ ├── ILocationService.cs │ │ │ │ └── LocationService.cs │ │ │ ├── Navigation/ │ │ │ │ ├── INavigationService.cs │ │ │ │ └── MauiNavigationService.cs │ │ │ ├── OpenUrl/ │ │ │ │ ├── IOpenUrlService.cs │ │ │ │ └── OpenUrlService.cs │ │ │ ├── Order/ │ │ │ │ ├── IOrderService.cs │ │ │ │ ├── OrderMockService.cs │ │ │ │ └── OrderService.cs │ │ │ ├── RequestProvider/ │ │ │ │ ├── HttpRequestExceptionEx.cs │ │ │ │ ├── IRequestProvider.cs │ │ │ │ └── RequestProvider.cs │ │ │ ├── Settings/ │ │ │ │ ├── ISettingsService.cs │ │ │ │ └── SettingsService.cs │ │ │ └── Theme/ │ │ │ ├── ITheme.cs │ │ │ └── Theme.shared.cs │ │ ├── Triggers/ │ │ │ └── BeginAnimation.cs │ │ ├── Validations/ │ │ │ ├── IValidationRule.cs │ │ │ ├── IValidity.cs │ │ │ ├── IsNotNullOrEmptyRule.cs │ │ │ └── ValidatableObject.cs │ │ ├── ViewModels/ │ │ │ ├── Base/ │ │ │ │ ├── IViewModelBase.cs │ │ │ │ └── ViewModelBase.cs │ │ │ ├── BasketViewModel.cs │ │ │ ├── CatalogItemViewModel.cs │ │ │ ├── CatalogViewModel.cs │ │ │ ├── CheckoutViewModel.cs │ │ │ ├── LoginViewModel.cs │ │ │ ├── MainViewModel.cs │ │ │ ├── MapViewModel.cs │ │ │ ├── ObservableCollectionEx.cs │ │ │ ├── OrderDetailViewModel.cs │ │ │ ├── ProfileViewModel.cs │ │ │ ├── SelectionViewModel.cs │ │ │ └── SettingsViewModel.cs │ │ └── Views/ │ │ ├── BadgeView.cs │ │ ├── BasketView.xaml │ │ ├── BasketView.xaml.cs │ │ ├── CatalogItemView.xaml │ │ ├── CatalogItemView.xaml.cs │ │ ├── CatalogView.xaml │ │ ├── CatalogView.xaml.cs │ │ ├── CheckoutView.xaml │ │ ├── CheckoutView.xaml.cs │ │ ├── ContentPageBase.cs │ │ ├── CustomNavigationView.xaml │ │ ├── CustomNavigationView.xaml.cs │ │ ├── FiltersView.xaml │ │ ├── FiltersView.xaml.cs │ │ ├── LoginView.xaml │ │ ├── LoginView.xaml.cs │ │ ├── MapView.xaml │ │ ├── MapView.xaml.cs │ │ ├── MauiAuthenticationBrowser.cs │ │ ├── OrderDetailView.xaml │ │ ├── OrderDetailView.xaml.cs │ │ ├── ProfileView.xaml │ │ ├── ProfileView.xaml.cs │ │ ├── SettingsView.xaml │ │ ├── SettingsView.xaml.cs │ │ └── Templates/ │ │ ├── BasketItemTemplate.xaml │ │ ├── BasketItemTemplate.xaml.cs │ │ ├── CampaignTemplate.xaml │ │ ├── CampaignTemplate.xaml.cs │ │ ├── OrderItemTemplate.xaml │ │ ├── OrderItemTemplate.xaml.cs │ │ ├── OrderTemplate.xaml │ │ ├── OrderTemplate.xaml.cs │ │ ├── ProductTemplate.xaml │ │ └── ProductTemplate.xaml.cs │ ├── EventBus/ │ │ ├── Abstractions/ │ │ │ ├── EventBusSubscriptionInfo.cs │ │ │ ├── IEventBus.cs │ │ │ ├── IEventBusBuilder.cs │ │ │ └── IIntegrationEventHandler.cs │ │ ├── EventBus.csproj │ │ ├── Events/ │ │ │ └── IntegrationEvent.cs │ │ ├── Extensions/ │ │ │ ├── EventBusBuilderExtensions.cs │ │ │ └── GenericTypeExtensions.cs │ │ └── GlobalUsings.cs │ ├── EventBusRabbitMQ/ │ │ ├── EventBusOptions.cs │ │ ├── EventBusRabbitMQ.csproj │ │ ├── GlobalUsings.cs │ │ ├── RabbitMQEventBus.cs │ │ ├── RabbitMQTelemetry.cs │ │ └── RabbitMqDependencyInjectionExtensions.cs │ ├── HybridApp/ │ │ ├── App.xaml │ │ ├── App.xaml.cs │ │ ├── Components/ │ │ │ ├── Layout/ │ │ │ │ ├── FooterBar.razor │ │ │ │ ├── FooterBar.razor.css │ │ │ │ ├── HeaderBar.razor │ │ │ │ ├── HeaderBar.razor.css │ │ │ │ ├── MainLayout.razor │ │ │ │ └── MainLayout.razor.css │ │ │ ├── Pages/ │ │ │ │ ├── Catalog/ │ │ │ │ │ ├── Catalog.razor │ │ │ │ │ ├── Catalog.razor.css │ │ │ │ │ ├── CatalogSearch.razor │ │ │ │ │ └── CatalogSearch.razor.css │ │ │ │ └── Item/ │ │ │ │ ├── ItemPage.razor │ │ │ │ └── ItemPage.razor.css │ │ │ ├── Routes.razor │ │ │ └── _Imports.razor │ │ ├── GlobalSuppressions.cs │ │ ├── HybridApp.csproj │ │ ├── MainPage.xaml │ │ ├── MainPage.xaml.cs │ │ ├── MauiProgram.cs │ │ ├── Platforms/ │ │ │ ├── Android/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── MainActivity.cs │ │ │ │ ├── MainApplication.cs │ │ │ │ └── Resources/ │ │ │ │ ├── values/ │ │ │ │ │ └── colors.xml │ │ │ │ └── xml/ │ │ │ │ └── network_security_config.xml │ │ │ ├── MacCatalyst/ │ │ │ │ ├── AppDelegate.cs │ │ │ │ ├── Entitlements.Debug.plist │ │ │ │ ├── Entitlements.Release.plist │ │ │ │ ├── Info.plist │ │ │ │ └── Program.cs │ │ │ ├── Tizen/ │ │ │ │ ├── Main.cs │ │ │ │ └── tizen-manifest.xml │ │ │ ├── Windows/ │ │ │ │ ├── App.xaml │ │ │ │ ├── App.xaml.cs │ │ │ │ ├── Package.appxmanifest │ │ │ │ └── app.manifest │ │ │ └── iOS/ │ │ │ ├── AppDelegate.cs │ │ │ ├── Info.plist │ │ │ └── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Resources/ │ │ │ └── Raw/ │ │ │ └── AboutAssets.txt │ │ ├── Services/ │ │ │ ├── CatalogJsonContext.cs │ │ │ ├── CatalogService.cs │ │ │ └── ProductImageUrlProvider.cs │ │ └── wwwroot/ │ │ ├── css/ │ │ │ ├── app.css │ │ │ └── normalize.css │ │ └── index.html │ ├── Identity.API/ │ │ ├── .gitignore │ │ ├── Configuration/ │ │ │ └── Config.cs │ │ ├── Data/ │ │ │ ├── ApplicationDbContext.cs │ │ │ └── Migrations/ │ │ │ ├── 20230925223402_InitialMigration.Designer.cs │ │ │ ├── 20230925223402_InitialMigration.cs │ │ │ └── ApplicationDbContextModelSnapshot.cs │ │ ├── GlobalUsings.cs │ │ ├── Identity.API.csproj │ │ ├── Models/ │ │ │ ├── AccountViewModels/ │ │ │ │ ├── ForgotPasswordViewModel.cs │ │ │ │ ├── LoggedOutViewModel.cs │ │ │ │ ├── LoginViewModel.cs │ │ │ │ ├── LogoutViewModel.cs │ │ │ │ ├── RedirectViewModel.cs │ │ │ │ ├── RegisterViewModel.cs │ │ │ │ ├── ResetPasswordViewModel.cs │ │ │ │ ├── SendCodeViewModel.cs │ │ │ │ └── VerifyCodeViewModel.cs │ │ │ ├── ApplicationUser.cs │ │ │ ├── ConsentViewModels/ │ │ │ │ ├── ConsentInputModel.cs │ │ │ │ ├── ConsentOptions.cs │ │ │ │ ├── ConsentViewModel.cs │ │ │ │ ├── ProcessConsentResult.cs │ │ │ │ └── ScopeViewModel.cs │ │ │ ├── ErrorViewModel.cs │ │ │ └── ManageViewModels/ │ │ │ ├── AddPhoneNumberViewModel.cs │ │ │ ├── ChangePasswordViewModel.cs │ │ │ ├── ConfigureTwoFactorViewModel.cs │ │ │ ├── FactorViewModel.cs │ │ │ ├── IndexViewModel.cs │ │ │ ├── SetPasswordViewModel.cs │ │ │ └── VerifyPhoneNumberViewModel.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Quickstart/ │ │ │ ├── Account/ │ │ │ │ ├── AccountController.cs │ │ │ │ ├── AccountOptions.cs │ │ │ │ ├── ExternalController.cs │ │ │ │ ├── ExternalProvider.cs │ │ │ │ ├── LoggedOutViewModel.cs │ │ │ │ ├── LoginInputModel.cs │ │ │ │ ├── LoginViewModel.cs │ │ │ │ ├── LogoutInputModel.cs │ │ │ │ ├── LogoutViewModel.cs │ │ │ │ └── RedirectViewModel.cs │ │ │ ├── Consent/ │ │ │ │ ├── ConsentController.cs │ │ │ │ ├── ConsentInputModel.cs │ │ │ │ ├── ConsentOptions.cs │ │ │ │ ├── ConsentViewModel.cs │ │ │ │ ├── ProcessConsentResult.cs │ │ │ │ └── ScopeViewModel.cs │ │ │ ├── Device/ │ │ │ │ ├── DeviceAuthorizationInputModel.cs │ │ │ │ ├── DeviceAuthorizationViewModel.cs │ │ │ │ └── DeviceController.cs │ │ │ ├── Diagnostics/ │ │ │ │ ├── DiagnosticsController.cs │ │ │ │ └── DiagnosticsViewModel.cs │ │ │ ├── Extensions.cs │ │ │ ├── Grants/ │ │ │ │ ├── GrantsController.cs │ │ │ │ └── GrantsViewModel.cs │ │ │ ├── Home/ │ │ │ │ ├── ErrorViewModel.cs │ │ │ │ └── HomeController.cs │ │ │ └── SecurityHeadersAttribute.cs │ │ ├── Services/ │ │ │ ├── EFLoginService.cs │ │ │ ├── ILoginService.cs │ │ │ ├── IRedirectService.cs │ │ │ ├── ProfileService.cs │ │ │ └── RedirectService.cs │ │ ├── UsersSeed.cs │ │ ├── Views/ │ │ │ ├── Account/ │ │ │ │ ├── AccessDenied.cshtml │ │ │ │ ├── LoggedOut.cshtml │ │ │ │ ├── Login.cshtml │ │ │ │ └── Logout.cshtml │ │ │ ├── Consent/ │ │ │ │ └── Index.cshtml │ │ │ ├── Device/ │ │ │ │ ├── Success.cshtml │ │ │ │ ├── UserCodeCapture.cshtml │ │ │ │ └── UserCodeConfirmation.cshtml │ │ │ ├── Diagnostics/ │ │ │ │ └── Index.cshtml │ │ │ ├── Grants/ │ │ │ │ └── Index.cshtml │ │ │ ├── Home/ │ │ │ │ └── Index.cshtml │ │ │ ├── Shared/ │ │ │ │ ├── Error.cshtml │ │ │ │ ├── Redirect.cshtml │ │ │ │ ├── _Layout.cshtml │ │ │ │ ├── _ScopeListItem.cshtml │ │ │ │ └── _ValidationSummary.cshtml │ │ │ ├── _ViewImports.cshtml │ │ │ └── _ViewStart.cshtml │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ ├── bundleconfig.json │ │ ├── libman.json │ │ ├── tempkey.jwk │ │ └── wwwroot/ │ │ ├── _references.js │ │ ├── css/ │ │ │ └── site.css │ │ └── js/ │ │ ├── signin-redirect.js │ │ ├── signout-redirect.js │ │ └── site.js │ ├── IntegrationEventLogEF/ │ │ ├── EventStateEnum.cs │ │ ├── GlobalUsings.cs │ │ ├── IntegrationEventLogEF.csproj │ │ ├── IntegrationEventLogEntry.cs │ │ ├── IntegrationLogExtensions.cs │ │ ├── Services/ │ │ │ ├── IIntegrationEventLogService.cs │ │ │ └── IntegrationEventLogService.cs │ │ └── Utilities/ │ │ └── ResilientTransaction.cs │ ├── OrderProcessor/ │ │ ├── BackgroundTaskOptions.cs │ │ ├── Events/ │ │ │ └── GracePeriodConfirmedIntegrationEvent.cs │ │ ├── Extensions/ │ │ │ └── Extensions.cs │ │ ├── GlobalUsings.cs │ │ ├── OrderProcessor.csproj │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Services/ │ │ │ └── GracePeriodManagerService.cs │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── Ordering.API/ │ │ ├── Apis/ │ │ │ ├── OrderServices.cs │ │ │ └── OrdersApi.cs │ │ ├── Application/ │ │ │ ├── Behaviors/ │ │ │ │ ├── LoggingBehavior.cs │ │ │ │ ├── TransactionBehavior.cs │ │ │ │ └── ValidatorBehavior.cs │ │ │ ├── Commands/ │ │ │ │ ├── CancelOrderCommand.cs │ │ │ │ ├── CancelOrderCommandHandler.cs │ │ │ │ ├── CreateOrderCommand.cs │ │ │ │ ├── CreateOrderCommandHandler.cs │ │ │ │ ├── CreateOrderDraftCommand.cs │ │ │ │ ├── CreateOrderDraftCommandHandler.cs │ │ │ │ ├── IdentifiedCommand.cs │ │ │ │ ├── IdentifiedCommandHandler.cs │ │ │ │ ├── SetAwaitingValidationOrderStatusCommand.cs │ │ │ │ ├── SetAwaitingValidationOrderStatusCommandHandler.cs │ │ │ │ ├── SetPaidOrderStatusCommand.cs │ │ │ │ ├── SetPaidOrderStatusCommandHandler.cs │ │ │ │ ├── SetStockConfirmedOrderStatusCommand.cs │ │ │ │ ├── SetStockConfirmedOrderStatusCommandHandler.cs │ │ │ │ ├── SetStockRejectedOrderStatusCommand.cs │ │ │ │ ├── SetStockRejectedOrderStatusCommandHandler.cs │ │ │ │ ├── ShipOrderCommand.cs │ │ │ │ └── ShipOrderCommandHandler.cs │ │ │ ├── DomainEventHandlers/ │ │ │ │ ├── OrderCancelledDomainEventHandler.cs │ │ │ │ ├── OrderShippedDomainEventHandler.cs │ │ │ │ ├── OrderStatusChangedToAwaitingValidationDomainEventHandler.cs │ │ │ │ ├── OrderStatusChangedToPaidDomainEventHandler.cs │ │ │ │ ├── OrderStatusChangedToStockConfirmedDomainEventHandler.cs │ │ │ │ ├── UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs │ │ │ │ └── ValidateOrAddBuyerAggregateWhenOrderStartedDomainEventHandler.cs │ │ │ ├── IntegrationEvents/ │ │ │ │ ├── EventHandling/ │ │ │ │ │ ├── GracePeriodConfirmedIntegrationEventHandler.cs │ │ │ │ │ ├── OrderPaymentFailedIntegrationEventHandler.cs │ │ │ │ │ ├── OrderPaymentSucceededIntegrationEventHandler.cs │ │ │ │ │ ├── OrderStockConfirmedIntegrationEventHandler.cs │ │ │ │ │ └── OrderStockRejectedIntegrationEventHandler.cs │ │ │ │ ├── Events/ │ │ │ │ │ ├── GracePeriodConfirmedIntegrationEvent.cs │ │ │ │ │ ├── OrderPaymentFailedIntegrationEvent .cs │ │ │ │ │ ├── OrderPaymentSucceededIntegrationEvent.cs │ │ │ │ │ ├── OrderStartedIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToAwaitingValidationIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToCancelledIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToPaidIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToShippedIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToStockConfirmedIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedTosubmittedIntegrationEvent.cs │ │ │ │ │ ├── OrderStockConfirmedIntegrationEvent.cs │ │ │ │ │ └── OrderStockRejectedIntegrationEvent.cs │ │ │ │ ├── IOrderingIntegrationEventService.cs │ │ │ │ └── OrderingIntegrationEventService.cs │ │ │ ├── Models/ │ │ │ │ ├── BasketItem.cs │ │ │ │ └── CustomerBasket.cs │ │ │ ├── Queries/ │ │ │ │ ├── IOrderQueries.cs │ │ │ │ ├── OrderQueries.cs │ │ │ │ └── OrderViewModel.cs │ │ │ └── Validations/ │ │ │ ├── CancelOrderCommandValidator.cs │ │ │ ├── CreateOrderCommandValidator.cs │ │ │ ├── IdentifiedCommandValidator.cs │ │ │ └── ShipOrderCommandValidator.cs │ │ ├── Extensions/ │ │ │ ├── BasketItemExtensions.cs │ │ │ ├── Extensions.cs │ │ │ ├── LinqSelectExtensions.cs │ │ │ └── OrderingApiTrace.cs │ │ ├── GlobalUsings.cs │ │ ├── Infrastructure/ │ │ │ ├── OrderingContextSeed.cs │ │ │ └── Services/ │ │ │ ├── IIdentityService.cs │ │ │ └── IdentityService.cs │ │ ├── Ordering.API.csproj │ │ ├── Program.Testing.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── Ordering.Domain/ │ │ ├── AggregatesModel/ │ │ │ ├── BuyerAggregate/ │ │ │ │ ├── Buyer.cs │ │ │ │ ├── CardType.cs │ │ │ │ ├── IBuyerRepository.cs │ │ │ │ └── PaymentMethod.cs │ │ │ └── OrderAggregate/ │ │ │ ├── Address.cs │ │ │ ├── IOrderRepository.cs │ │ │ ├── Order.cs │ │ │ ├── OrderItem.cs │ │ │ └── OrderStatus.cs │ │ ├── Events/ │ │ │ ├── BuyerPaymentMethodVerifiedDomainEvent.cs │ │ │ ├── OrderCancelledDomainEvent.cs │ │ │ ├── OrderShippedDomainEvent.cs │ │ │ ├── OrderStartedDomainEvent.cs │ │ │ ├── OrderStatusChangedToAwaitingValidationDomainEvent.cs │ │ │ ├── OrderStatusChangedToPaidDomainEvent.cs │ │ │ └── OrderStatusChangedToStockConfirmedDomainEvent.cs │ │ ├── Exceptions/ │ │ │ └── OrderingDomainException.cs │ │ ├── GlobalUsings.cs │ │ ├── Ordering.Domain.csproj │ │ └── SeedWork/ │ │ ├── Entity.cs │ │ ├── IAggregateRoot.cs │ │ ├── IRepository.cs │ │ ├── IUnitOfWork.cs │ │ └── ValueObject.cs │ ├── Ordering.Infrastructure/ │ │ ├── EntityConfigurations/ │ │ │ ├── BuyerEntityTypeConfiguration.cs │ │ │ ├── CardTypeEntityTypeConfiguration.cs │ │ │ ├── ClientRequestEntityTypeConfiguration.cs │ │ │ ├── OrderEntityTypeConfiguration.cs │ │ │ ├── OrderItemEntityTypeConfiguration.cs │ │ │ └── PaymentMethodEntityTypeConfiguration.cs │ │ ├── GlobalUsings.cs │ │ ├── Idempotency/ │ │ │ ├── ClientRequest.cs │ │ │ ├── IRequestManager.cs │ │ │ └── RequestManager.cs │ │ ├── MediatorExtension.cs │ │ ├── Migrations/ │ │ │ ├── 20230925222426_Initial.Designer.cs │ │ │ ├── 20230925222426_Initial.cs │ │ │ ├── 20231021004633_FixOrderitemseqSchema.Designer.cs │ │ │ ├── 20231021004633_FixOrderitemseqSchema.cs │ │ │ ├── 20231026091055_Outbox.Designer.cs │ │ │ ├── 20231026091055_Outbox.cs │ │ │ ├── 20240106121712_UseEnumForOrderStatus.Designer.cs │ │ │ ├── 20240106121712_UseEnumForOrderStatus.cs │ │ │ └── OrderingContextModelSnapshot.cs │ │ ├── Ordering.Infrastructure.csproj │ │ ├── OrderingContext.cs │ │ └── Repositories/ │ │ ├── BuyerRepository.cs │ │ └── OrderRepository.cs │ ├── PaymentProcessor/ │ │ ├── GlobalUsings.cs │ │ ├── IntegrationEvents/ │ │ │ ├── EventHandling/ │ │ │ │ └── OrderStatusChangedToStockConfirmedIntegrationEventHandler.cs │ │ │ └── Events/ │ │ │ ├── OrderPaymentFailedIntegrationEvent.cs │ │ │ ├── OrderPaymentSucceededIntegrationEvent.cs │ │ │ └── OrderStatusChangedToStockConfirmedIntegrationEvent.cs │ │ ├── PaymentOptions.cs │ │ ├── PaymentProcessor.csproj │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── Shared/ │ │ ├── ActivityExtensions.cs │ │ └── MigrateDbContextExtensions.cs │ ├── WebApp/ │ │ ├── Components/ │ │ │ ├── App.razor │ │ │ ├── Chatbot/ │ │ │ │ ├── ChatState.cs │ │ │ │ ├── Chatbot.razor │ │ │ │ ├── Chatbot.razor.css │ │ │ │ ├── Chatbot.razor.js │ │ │ │ ├── MessageProcessor.cs │ │ │ │ ├── ShowChatbotButton.razor │ │ │ │ └── ShowChatbotButton.razor.css │ │ │ ├── Layout/ │ │ │ │ ├── CartMenu.razor │ │ │ │ ├── CartMenu.razor.css │ │ │ │ ├── FooterBar.razor │ │ │ │ ├── FooterBar.razor.css │ │ │ │ ├── HeaderBar.razor │ │ │ │ ├── HeaderBar.razor.css │ │ │ │ ├── MainLayout.razor │ │ │ │ ├── MainLayout.razor.css │ │ │ │ ├── UserMenu.razor │ │ │ │ └── UserMenu.razor.css │ │ │ ├── Pages/ │ │ │ │ ├── Cart/ │ │ │ │ │ ├── CartPage.razor │ │ │ │ │ └── CartPage.razor.css │ │ │ │ ├── Catalog/ │ │ │ │ │ ├── Catalog.razor │ │ │ │ │ └── Catalog.razor.css │ │ │ │ ├── Checkout/ │ │ │ │ │ ├── Checkout.razor │ │ │ │ │ └── Checkout.razor.css │ │ │ │ ├── Item/ │ │ │ │ │ ├── ItemPage.razor │ │ │ │ │ └── ItemPage.razor.css │ │ │ │ └── User/ │ │ │ │ ├── LogIn.razor │ │ │ │ ├── LogOut.razor │ │ │ │ ├── Orders.razor │ │ │ │ ├── Orders.razor.css │ │ │ │ └── OrdersRefreshOnStatusChange.razor │ │ │ ├── Routes.razor │ │ │ └── _Imports.razor │ │ ├── Extensions/ │ │ │ └── Extensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Services/ │ │ │ ├── BasketCheckoutInfo.cs │ │ │ ├── BasketItem.cs │ │ │ ├── BasketService.cs │ │ │ ├── BasketState.cs │ │ │ ├── IBasketState.cs │ │ │ ├── LogOutService.cs │ │ │ ├── OrderStatus/ │ │ │ │ ├── IntegrationEvents/ │ │ │ │ │ ├── EventHandling/ │ │ │ │ │ │ ├── OrderStatusChangedToAwaitingValidationIntegrationEventHandler.cs │ │ │ │ │ │ ├── OrderStatusChangedToCancelledIntegrationEventHandler.cs │ │ │ │ │ │ ├── OrderStatusChangedToPaidIntegrationEventHandler.cs │ │ │ │ │ │ ├── OrderStatusChangedToShippedIntegrationEventHandler.cs │ │ │ │ │ │ ├── OrderStatusChangedToStockConfirmedIntegrationEventHandler.cs │ │ │ │ │ │ └── OrderStatusChangedToSubmittedIntegrationEventHandler.cs │ │ │ │ │ └── Events/ │ │ │ │ │ ├── OrderStatusChangedToAwaitingValidationIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToCancelledIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToPaidIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToShippedIntegrationEvent.cs │ │ │ │ │ ├── OrderStatusChangedToStockConfirmedIntegrationEvent.cs │ │ │ │ │ └── OrderStatusChangedToSubmittedIntegrationEvent.cs │ │ │ │ └── OrderStatusNotificationService.cs │ │ │ ├── OrderingService.cs │ │ │ └── ProductImageUrlProvider.cs │ │ ├── WebApp.csproj │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ └── wwwroot/ │ │ └── css/ │ │ ├── app.css │ │ └── normalize.css │ ├── WebAppComponents/ │ │ ├── Catalog/ │ │ │ ├── CatalogItem.cs │ │ │ ├── CatalogListItem.razor │ │ │ ├── CatalogListItem.razor.css │ │ │ ├── CatalogSearch.razor │ │ │ └── CatalogSearch.razor.css │ │ ├── Item/ │ │ │ └── ItemHelper.cs │ │ ├── Services/ │ │ │ ├── CatalogService.cs │ │ │ ├── ICatalogService.cs │ │ │ └── IProductImageUrlProvider.cs │ │ ├── WebAppComponents.csproj │ │ └── _Imports.razor │ ├── WebhookClient/ │ │ ├── Components/ │ │ │ ├── App.razor │ │ │ ├── App.razor.css │ │ │ ├── Layout/ │ │ │ │ ├── MainLayout.razor │ │ │ │ ├── MainLayout.razor.css │ │ │ │ ├── UserMenu.razor │ │ │ │ └── UserMenu.razor.css │ │ │ ├── Pages/ │ │ │ │ ├── AddWebhook.razor │ │ │ │ ├── Error.razor │ │ │ │ ├── Home/ │ │ │ │ │ ├── Home.razor │ │ │ │ │ ├── Home.razor.css │ │ │ │ │ ├── ReceivedMessages.razor │ │ │ │ │ └── RegisteredHooks.razor │ │ │ │ └── LogIn.razor │ │ │ ├── Routes.razor │ │ │ └── _Imports.razor │ │ ├── Endpoints/ │ │ │ ├── AuthenticationEndpoints.cs │ │ │ └── WebhookEndpoints.cs │ │ ├── Extensions/ │ │ │ └── Extensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Services/ │ │ │ ├── HooksRepository.cs │ │ │ ├── WebHookReceived.cs │ │ │ ├── WebHooksClient.cs │ │ │ ├── WebhookClientOptions.cs │ │ │ ├── WebhookData.cs │ │ │ ├── WebhookResponse.cs │ │ │ ├── WebhookSubscriptionRequest.cs │ │ │ └── WebhookType.cs │ │ ├── WebhookClient.csproj │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ └── wwwroot/ │ │ └── app.css │ ├── Webhooks.API/ │ │ ├── Apis/ │ │ │ └── WebHooksApi.cs │ │ ├── Exceptions/ │ │ │ └── WebhooksDomainException.cs │ │ ├── Extensions/ │ │ │ ├── Extensions.cs │ │ │ └── RouteHandlerBuilderExtensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Infrastructure/ │ │ │ └── WebhooksContext.cs │ │ ├── IntegrationEvents/ │ │ │ ├── OrderStatusChangedToPaidIntegrationEvent.cs │ │ │ ├── OrderStatusChangedToPaidIntegrationEventHandler.cs │ │ │ ├── OrderStatusChangedToShippedIntegrationEvent.cs │ │ │ ├── OrderStatusChangedToShippedIntegrationEventHandler.cs │ │ │ ├── OrderStockItem.cs │ │ │ ├── ProductPriceChangedIntegrationEvent.cs │ │ │ └── ProductPriceChangedIntegrationEventHandler.cs │ │ ├── Migrations/ │ │ │ ├── 20230925222606_Initial.Designer.cs │ │ │ ├── 20230925222606_Initial.cs │ │ │ └── WebhooksContextModelSnapshot.cs │ │ ├── Model/ │ │ │ ├── WebhookData.cs │ │ │ ├── WebhookSubscription.cs │ │ │ ├── WebhookSubscriptionRequest.cs │ │ │ └── WebhookType.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Services/ │ │ │ ├── GrantUrlTesterService.cs │ │ │ ├── IGrantUrlTesterService.cs │ │ │ ├── IWebhooksRetriever.cs │ │ │ ├── IWebhooksSender.cs │ │ │ ├── WebhooksRetriever.cs │ │ │ └── WebhooksSender.cs │ │ ├── Webhooks.API.csproj │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── eShop.AppHost/ │ │ ├── Extensions.cs │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── appsettings.json │ │ └── eShop.AppHost.csproj │ └── eShop.ServiceDefaults/ │ ├── AuthenticationExtensions.cs │ ├── ClaimsPrincipalExtensions.cs │ ├── ConfigurationExtensions.cs │ ├── Extensions.cs │ ├── HttpClientExtensions.cs │ ├── OpenApi.Extensions.cs │ ├── OpenApiOptionsExtensions.cs │ └── eShop.ServiceDefaults.csproj └── tests/ ├── Basket.UnitTests/ │ ├── Basket.UnitTests.csproj │ ├── BasketServiceTests.cs │ ├── GlobalUsings.cs │ └── Helpers/ │ └── TestServerCallContext.cs ├── Catalog.FunctionalTests/ │ ├── Catalog.FunctionalTests.csproj │ ├── CatalogApiFixture.cs │ ├── CatalogApiTests.cs │ └── GlobalUsings.cs ├── ClientApp.UnitTests/ │ ├── ClientApp.UnitTests.csproj │ ├── ClientApp.UnitTests.sln │ ├── GlobalUsings.cs │ ├── Mocks/ │ │ ├── MockDialogService.cs │ │ ├── MockNavigationService.cs │ │ ├── MockSettingsService.cs │ │ └── MockViewModel.cs │ ├── Services/ │ │ ├── BasketServiceTests.cs │ │ ├── CatalogServiceTests.cs │ │ └── OrdersServiceTests.cs │ ├── TestingExtensions.cs │ └── ViewModels/ │ ├── CatalogItemViewModelTests.cs │ ├── CatalogViewModelTests.cs │ ├── MainViewModelTests.cs │ ├── MockViewModelTests.cs │ └── OrderViewModelTests.cs ├── Directory.Build.props ├── Ordering.FunctionalTests/ │ ├── AutoAuthorizeMiddleware.cs │ ├── GlobalUsings.cs │ ├── Ordering.FunctionalTests.csproj │ ├── OrderingApiFixture.cs │ └── OrderingApiTests.cs ├── Ordering.UnitTests/ │ ├── Application/ │ │ ├── IdentifiedCommandHandlerTest.cs │ │ ├── NewOrderCommandHandlerTest.cs │ │ ├── OrdersWebApiTest.cs │ │ └── SetStockRejectedOrderStatusCommandTest.cs │ ├── Builders.cs │ ├── Domain/ │ │ ├── BuyerAggregateTest.cs │ │ ├── OrderAggregateTest.cs │ │ └── SeedWork/ │ │ └── ValueObjectTests.cs │ ├── GlobalUsings.cs │ └── Ordering.UnitTests.csproj └── README.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .aspire/settings.json ================================================ { "appHostPath": "../src/eShop.AppHost/eShop.AppHost.csproj" } ================================================ FILE: .config/CredScanSuppressions.json ================================================ { "tool": "Credential Scanner", "suppressions": [ { "placeholder": "Pass123$", "_justification": "Dummy." }, { "placeholder": "yourWeak(!)Password", "_justification": "Dummy." } ] } ================================================ FILE: .config/configuration.vs.winget ================================================ # yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 properties: configurationVersion: 0.2.0 ######################################## ### RESOURCES: System Configuration ######################################## resources: ######################################## ### OS Configurations ######################################## ### Enable: Developer Mode ### ------------------------------------- - resource: Microsoft.Windows.Developer/DeveloperMode directives: description: Enable Developer Mode # Requires elevation only for the set operation securityContext: elevated allowPrerelease: true settings: Ensure: Present ### Install Windows VirtualMachinePlatform ### ------------------------------------- - resource: PSDscResources/WindowsOptionalFeature directives: description: Install VirtualMachinePlatform securityContext: elevated settings: name: VirtualMachinePlatform ensure: Present ### Install WSL ### ------------------------------------- - resource: PSDscResources/WindowsOptionalFeature directives: description: Install WSL securityContext: elevated settings: name: Microsoft-Windows-Subsystem-Linux ensure: Present ### Configure Install Ubuntu ### ------------------------------------- - resource: PSDscResources/Script id: ubuntuwsl directives: description: Install Ubuntu for WSL settings: SetScript: | $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") wsl --install -d Ubuntu GetScript: return $false TestScript: return $false ######################################## ### Install CLIs, SDKs & Tools ######################################## ### Install DotNET SDK Preview ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: dotnetsdk directives: description: Install DotNET SDK Preview # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Microsoft.DotNet.SDK.Preview ### Install Azure CLI ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: azurecli directives: description: Install Azure CLI # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Microsoft.AzureCLI ### Install Azd ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: Azd directives: description: Install Azd settings: id: Microsoft.Azd ### Install Docker Desktop ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: docker directives: description: Install Docker Desktop # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Docker.DockerDesktop ### Install Visual Sudio ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: vscommunity directives: description: Install Visual Studio 2022 Community # Requires elevation only for the set operation (i.e., installing the package) # Only requires elevation when the VS Installer isn't installed yet securityContext: elevated settings: id: Microsoft.VisualStudio.2022.Community.Preview ### Install VS Workloads ### ------------------------------------- - resource: Microsoft.VisualStudio.DSC/VSComponents directives: description: Install required VS workloads from vsconfig file securityContext: elevated allowPrerelease: true dependsOn: - vscommunity settings: productId: Microsoft.VisualStudio.Product.Community channelId: VisualStudio.17.Preview components: [Microsoft.VisualStudio.Workload.NetWeb, Microsoft.VisualStudio.Workload.NetCrossPlat, aspire] includeRecommended: true ================================================ FILE: .config/configuration.vsCode.winget ================================================ # yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 properties: configurationVersion: 0.2.0 ######################################## ### RESOURCES: System Configuration ######################################## resources: ######################################## ### OS Configurations ######################################## ### Enable: Developer Mode ### ------------------------------------- - resource: Microsoft.Windows.Developer/DeveloperMode directives: description: Enable Developer Mode # Requires elevation only for the set operation securityContext: elevated allowPrerelease: true settings: Ensure: Present ### Install Windows VirtualMachinePlatform ### ------------------------------------- - resource: PSDscResources/WindowsOptionalFeature directives: description: Install VirtualMachinePlatform securityContext: elevated settings: name: VirtualMachinePlatform ensure: Present ### Install WSL ### ------------------------------------- - resource: PSDscResources/WindowsOptionalFeature directives: description: Install WSL securityContext: elevated settings: name: Microsoft-Windows-Subsystem-Linux ensure: Present ### Configure Install Ubuntu ### ------------------------------------- - resource: PSDscResources/Script id: ubuntuwsl directives: description: Install Ubuntu for WSL settings: SetScript: | $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") wsl --install -d Ubuntu GetScript: return $false TestScript: return $false ######################################## ### Install CLIs, SDKs & Tools ######################################## ### Install DotNET SDK Preview ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: dotnetsdk directives: description: Install DotNET SDK Preview # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Microsoft.DotNet.SDK.Preview ### Install Azure CLI ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: azurecli directives: description: Install Azure CLI # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Microsoft.AzureCLI ### Install Azd ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: Azd directives: description: Install Azd settings: id: Microsoft.Azd ### Install Docker Desktop ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: docker directives: description: Install Docker Desktop # Requires elevation only for the set operation (i.e., installing the package) securityContext: elevated settings: id: Docker.DockerDesktop ### Install Microsoft Visual Studio Code ### ------------------------------------- - resource: Microsoft.WinGet.DSC/WinGetPackage id: vscode directives: description: Install Microsoft Visual Studio Code settings: id: Microsoft.VisualStudioCode ensure: Present ######################################## ### Install VSCode Extensions ######################################## ### Install VSCode Azure Developer CLI Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: azure-dev-cli-extension dependsOn: - vscode - docker directives: description: Install Azure Developer CLI Extension allowPrerelease: true settings: name: ms-azuretools.azure-dev exist: true ### Install VSCode WSL Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: wsl-extension dependsOn: - vscode - docker directives: description: Install WSL extension allowPrerelease: true settings: name: ms-vscode-remote.remote-wsl exist: true ### Install VSCode Dev Containers Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: devcontainers-extension dependsOn: - vscode - docker directives: description: Install Dev Containers extension allowPrerelease: true settings: name: ms-vscode-remote.remote-containers exist: true ### Install VSCode Docker Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: docker-extension dependsOn: - vscode - docker directives: description: Install Docker extension allowPrerelease: true settings: name: ms-azuretools.vscode-docker exist: true ### Install VSCode C# DevKit Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: c#-devkit-extension dependsOn: - vscode - docker directives: description: Install C# DevKit extension allowPrerelease: true settings: name: ms-dotnettools.csdevkit exist: true ### Install .NET MAUI Extension ### ------------------------------------- - resource: Microsoft.VSCode.Dsc/VSCodeExtension id: dotnet-maui-extension dependsOn: - vscode - docker directives: description: Install .NET MAUI extension allowPrerelease: true settings: name: ms-dotnettools.dotnet-maui exist: true ================================================ FILE: .config/tsaoptions.json ================================================ { "areaPath": "DevDiv\\ASP.NET Core\\Policy Violations", "codebaseName": "eShop", "instanceUrl": "https://devdiv.visualstudio.com/", "iterationPath": "DevDiv", "notificationAliases": [ "aspnetcore-build@microsoft.com" ], "projectName": "DEVDIV", "repositoryName": "eShop", "template": "TFSDEVDIV" } ================================================ FILE: .devcenter/catalog/definitions/imagedefinition.yaml ================================================ Initial Commit ================================================ FILE: .editorconfig ================================================ ############################### # Core EditorConfig Options # ############################### root = true # All files [*] indent_style = space # XML project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] indent_size = 2 # XML config files [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] indent_size = 2 # Code files [*.{cs,csx,vb,vbx}] indent_size = 4 insert_final_newline = true charset = utf-8-bom ############################### # .NET Coding Conventions # ############################### [*.{cs,vb}] # Organize usings dotnet_sort_system_directives_first = true # this. preferences dotnet_style_qualification_for_field = false:silent dotnet_style_qualification_for_property = false:silent dotnet_style_qualification_for_method = false:silent dotnet_style_qualification_for_event = false:silent # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:silent dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent # Modifier preferences dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent dotnet_style_readonly_field = true:suggestion # Expression-level preferences dotnet_style_object_initializer = true:suggestion dotnet_style_collection_initializer = true:suggestion dotnet_style_explicit_tuple_names = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_coalesce_expression = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent dotnet_style_prefer_inferred_tuple_names = true:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_auto_properties = true:silent dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = true:silent ############################### # Naming Conventions # ############################### # Style Definitions dotnet_naming_style.pascal_case_style.capitalization = pascal_case # Use PascalCase for constant fields dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style dotnet_naming_symbols.constant_fields.applicable_kinds = field dotnet_naming_symbols.constant_fields.applicable_accessibilities = * dotnet_naming_symbols.constant_fields.required_modifiers = const ############################### # C# Coding Conventions # ############################### [*.cs] # var preferences csharp_style_var_for_built_in_types = true:silent csharp_style_var_when_type_is_apparent = true:silent csharp_style_var_elsewhere = true:silent # Expression-bodied members csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_properties = true:silent csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_accessors = true:silent # Pattern matching preferences csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion csharp_style_pattern_matching_over_as_with_null_check = true:suggestion # Null-checking preferences csharp_style_throw_expression = true:suggestion csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion # Expression-level preferences csharp_prefer_braces = true:silent csharp_style_deconstructed_variable_declaration = true:suggestion csharp_prefer_simple_default_expression = true:suggestion csharp_style_prefer_local_over_anonymous_function = true:suggestion csharp_style_inlined_variable_declaration = true:suggestion ############################### # C# Formatting Rules # ############################### # New line preferences csharp_new_line_before_open_brace = all csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences csharp_indent_case_contents = true csharp_indent_switch_labels = true csharp_indent_labels = flush_left # Space preferences csharp_space_after_cast = false csharp_space_after_keywords_in_control_flow_statements = true csharp_space_between_method_call_parameter_list_parentheses = false csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_before_colon_in_inheritance_clause = true csharp_space_after_colon_in_inheritance_clause = true csharp_space_around_binary_operators = before_and_after csharp_space_between_method_declaration_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true ############################### # VB Coding Conventions # ############################### [*.vb] # Modifier preferences visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion ================================================ FILE: .gitattributes ================================================ ############################################################################### # Set default behavior to automatically normalize line endings. ############################################################################### * text=auto *.sh text eol=lf ############################################################################### # Set default behavior for command prompt diff. # # This is need for earlier builds of msysgit that does not have it on by # default for csharp files. # Note: This is only used by command line ############################################################################### #*.cs diff=csharp ############################################################################### # Set the merge driver for project and solution files # # Merging from the command prompt will add diff markers to the files if there # are conflicts (Merging from VS is not affected by the settings below, in VS # the diff markers are never inserted). Diff markers may cause the following # file extensions to fail to load in VS. An alternative would be to treat # these files as binary and thus will always conflict and require user # intervention with every merge. To do so, just uncomment the entries below ############################################################################### #*.sln merge=binary #*.csproj merge=binary #*.vbproj merge=binary #*.vcxproj merge=binary #*.vcproj merge=binary #*.dbproj merge=binary #*.fsproj merge=binary #*.lsproj merge=binary #*.wixproj merge=binary #*.modelproj merge=binary #*.sqlproj merge=binary #*.wwaproj merge=binary ############################################################################### # behavior for image files # # image files are treated as binary by default. ############################################################################### #*.jpg binary #*.png binary #*.gif binary ############################################################################### # diff behavior for common document formats # # Convert binary document formats to text before diffing them. This feature # is only available from the command line. Turn it on by uncommenting the # entries below. ############################################################################### #*.doc diff=astextplain #*.DOC diff=astextplain #*.docx diff=astextplain #*.DOCX diff=astextplain #*.dot diff=astextplain #*.DOT diff=astextplain #*.pdf diff=astextplain #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain ############################################################################### # Certificates are binary ############################################################################### *.pfx binary ================================================ FILE: .github/dependabot.yml ================================================ version: 2 registries: public-nuget: type: nuget-feed url: https://api.nuget.org/v3/index.json updates: - package-ecosystem: nuget directory: "/" registries: - public-nuget schedule: interval: weekly open-pull-requests-limit: 15 groups: Aspire: patterns: - "Aspire.*" - "Microsoft.Extensions.ServiceDiscovery.*" Azure: patterns: - "Azure.*" - "Microsoft.Azure.*" - "Microsoft.Extensions.Azure" AspNetCoreHealthChecks: patterns: - "AspNetCore.HealthChecks.*" AspNetCore: patterns: - "Microsoft.AspNetCore.*" - "Microsoft.Extensions.Features" MicrosoftExtensions: patterns: - "Microsoft.Extensions.*" - "Microsoft.Bcl.*" EntityFrameworkCore: patterns: - "Microsoft.EntityFrameworkCore.*" FluentUi: patterns: - "Microsoft.FluentUI.*" OpenTelemetry: patterns: - "OpenTelemetry.*" Npgsql: patterns: - "Npgsql.*" MicrosoftDotNet: patterns: - "Microsoft.DotNet.*" Grpc: patterns: - "Grpc.*" Pgvector: patterns: - "Pgvector.*" Duende: patterns: - "Duende.*" MSTest: patterns: - "MSTest.*" ================================================ FILE: .github/workflows/markdownlint-problem-matcher.json ================================================ { "problemMatcher": [ { "owner": "markdownlint", "pattern": [ { "regexp": "^([^:]*):(\\d+):?(\\d+)?\\s([\\w-\\/]*)\\s(.*)$", "file": 1, "line": 2, "column": 3, "code": 4, "message": 5 } ] } ] } ================================================ FILE: .github/workflows/markdownlint.yml ================================================ name: Markdownlint permissions: contents: read # run even on changes without markdown changes, so that we can # make it in GitHub a required check for PR's on: pull_request: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 16.x - name: Run Markdownlint run: | echo "::add-matcher::.github/workflows/markdownlint-problem-matcher.json" npm i -g markdownlint-cli markdownlint --ignore '.dotnet/' '**/*.md' ================================================ FILE: .github/workflows/playwright.yml ================================================ name: Playwright Tests for eShop on: push: branches: [ main ] paths-ignore: - '**.md' - 'src/ClientApp/**' - 'tests/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' pull_request: branches: [ main ] paths-ignore: - '**.md' - 'src/ClientApp/**' - 'test/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' dotnet-quality: 'preview' - uses: actions/setup-node@v4 with: node-version: lts/* - name: Install dependencies run: npm ci - name: Install .NET HTTPS Development Certificate # if: matrix.os == 'ubuntu-latest' run: | dotnet dev-certs https --clean dotnet dev-certs https --trust - name: Install Playwright Browsers run: npx playwright install chromium - name: Run Playwright tests run: npx playwright test env: ESHOP_USE_HTTP_ENDPOINTS: 1 USERNAME1: bob PASSWORD: Pass123$ - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 ================================================ FILE: .github/workflows/pr-validation-maui.yml ================================================ name: eShop Pull Request Validation - .NET MAUI on: pull_request: branches: - '**' paths: - 'src/ClientApp/**' - 'tests/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' push: branches: - main paths: - 'src/ClientApp/**' - 'tests/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' jobs: test: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup .NET (global.json) uses: actions/setup-dotnet@v3 - name: Update Workloads run: dotnet workload update - name: Install Workloads shell: pwsh run: | dotnet workload install android dotnet workload install ios dotnet workload install maccatalyst dotnet workload install maui - name: Build run: dotnet build src/ClientApp/ClientApp.csproj - name: Test run: dotnet test --project tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj --no-progress --output detailed ================================================ FILE: .github/workflows/pr-validation.yml ================================================ name: eShop Pull Request Validation on: pull_request: paths-ignore: - '**.md' - 'src/ClientApp/**' - 'tests/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' push: branches: - main paths-ignore: - '**.md' - 'src/ClientApp/**' - 'tests/ClientApp.UnitTests/**' - '.github/workflows/pr-validation-maui.yml' jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET (global.json) uses: actions/setup-dotnet@v3 - name: Build run: dotnet build eShop.Web.slnf - name: Test run: dotnet test --solution eShop.Web.slnf --no-build --no-progress --output detailed ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from `dotnet new gitignore` # dotenv files .env # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET project.lock.json project.fragment.lock.json artifacts/ # Tye .tye/ # ASP.NET Scaffolding ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool coverage*.json coverage*.xml coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio 6 auto-generated project file (contains which files were open etc.) *.vbp # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files *.ncb *.aps # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # Visual Studio History (VSHistory) files .vshistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace # Local History for Visual Studio Code .history/ # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp # JetBrains Rider *.sln.iml .idea ## ## Visual studio for Mac ## # globs Makefile.in *.userprefs *.usertasks config.make config.status aclocal.m4 install-sh autom4te.cache/ *.tar.gz tarballs/ test-results/ # Mac bundle stuff *.dmg *.app # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk # Vim temporary swap files *.swp # Playwright /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /playwright/.auth/ /user.json .azure ================================================ FILE: .markdownlint.json ================================================ { "default": true, "ul-indent": false, "ul-style": false, "no-trailing-spaces": false, "line-length": false, "blanks-around-headings": false, "no-duplicate-heading": { "siblings_only": true }, "no-trailing-punctuation": false, "blanks-around-fences": false, "blanks-around-lists": false, "no-inline-html": { "allowed_elements": [ "summary", "details", "kbd", "br" ]}, "no-bare-urls": false, "single-trailing-newline": false, "emphasis-style": false, // rule settings and options are documented in https://github.com/DavidAnson/markdownlint // feel free to disable more low value rules in here; get rule name from the error message. // the purpose of the linter is to catch significant issues like broken links. } ================================================ FILE: .markdownlintignore ================================================ # This is for editors; keep .github/workflows/markdownlint.yml in sync .dotnet/ ================================================ FILE: .spectral.yml ================================================ extends: spectral:oas rules: info-contact: off success-response: description: All operations should have a success response. message: Operation is missing a success response. severity: warn given: $.paths.*.*.responses then: function: schema functionOptions: schema: anyOf: - required: ["200"] - required: ["201"] - required: ["204"] error-response: description: All operations should have a error response. message: Operation is missing a error response. severity: warn given: $.paths.*.*.responses then: function: schema functionOptions: schema: anyOf: - required: ["400"] - required: ["404"] parameter-description: description: All parameters should have a description message: Parameter is missing a description. severity: warn given: $.paths.*.*.parameters[*] then: field: description function: truthy ================================================ FILE: CODE-OF-CONDUCT.md ================================================ # Code of Conduct This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to eShop Thank you for your interest in contributing to eShop! We're excited to collaborate with you and see how together we can improve and evolve this sample application. ## Getting Started If this is your first visit, a great way to begin is by tackling issues tagged as `"help wanted"` or `"good first issue"`. These are specially curated to help you get acquainted with the project and make a meaningful impact early on. ## Spot a Typo? Typos and other small fixes are important to us -— no contribution is too small! If you spot something small, go ahead and open a Pull Request. For these types of contributions, there's no need to create a separate issue. ## Have a Suggestion? If you have a suggestion on how to enhance eShop, please open an issue with the following details: - A clear title and description of the suggestion - Any relevant examples or mockups - Indicate whether you're interested in implementing the feature yourself We'll review your suggestion and have a discussion about its potential inclusion in the project. ## Contribution Principles When considering contributions, we're guided by several principles that align with the project's vision: - **Best Practices**: We want this sample to be canonical and reflect the best practices in the industry and in .NET. - **Selectivity in Tools and Libraries**: There is a rich ecosystem of tools, projects, and libraries out there, and we cannot use all of them in this sample. We would like this repo to reflect the use of a realistic set of technologies, not to be a showcase or example of every possible thing it could use. - **Architectural Integrity**: We welcome refactoring and architectural improvements, provided they're justified. Large-scale changes should come with a clear rationale, such as significant enhancements to the application's design or performance. - **Enhancing Reliability and Scalability**: We welcome contributions that improve the application's reliability and scalability. These could include updates to error handling, redundancy mechanisms, data access, and any other changes that help eShop operate more robustly under load. We'd love to see relevant test scenarios or metrics for these contributions. - **Performance Enhancements**: If you're looking to speed up eShop, we're all for it! Please include benchmark comparisons to demonstrate the improvements. Performance improvements that make the code less readable or canonical may have more scrutiny applied. ## Code of Conduct To ensure a welcoming and positive environment for everyone, please adhere to our Code of Conduct. Respectful collaboration is key to a successful project. ================================================ FILE: Directory.Build.props ================================================ false true true embedded true enable NU1901;NU1902;NU1903;NU1904 true ================================================ FILE: Directory.Build.targets ================================================ true false ================================================ FILE: Directory.Packages.props ================================================ true true 10.0.0 10.1.0 13.1.0 13.1.0-preview.1.25616.3 2.71.0 7.3.1 8.1.0 runtime; build; native; contentfiles; analyzers; buildtransitive all ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) .NET Foundation and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # eShop Reference Application - "AdventureWorks" A reference .NET application implementing an e-commerce website using a services-based architecture using [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/). ![eShop Reference Application architecture diagram](img/eshop_architecture.png) ![eShop homepage screenshot](img/eshop_homepage.png) ## Getting Started This version of eShop is based on .NET 9. Previous eShop versions: * [.NET 8](https://github.com/dotnet/eShop/tree/release/8.0) ### Prerequisites - Clone the eShop repository: https://github.com/dotnet/eshop - [Install & start Docker Desktop](https://docs.docker.com/engine/install/) #### Windows with Visual Studio - Install [Visual Studio 2022 version 17.10 or newer](https://visualstudio.microsoft.com/vs/). - Select the following workloads: - `ASP.NET and web development` workload. - `.NET Aspire SDK` component in `Individual components`. - Optional: `.NET Multi-platform App UI development` to run client apps Or - Run the following commands in a Powershell & Terminal running as `Administrator` to automatically configure your environment with the required tools to build and run this application. (Note: A restart is required and included in the script below.) ```powershell install-Module -Name Microsoft.WinGet.Configuration -AllowPrerelease -AcceptLicense -Force $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") get-WinGetConfiguration -file .\.configurations\vside.dsc.yaml | Invoke-WinGetConfiguration -AcceptConfigurationAgreements ``` Or - From Dev Home go to `Machine Configuration -> Clone repositories`. Enter the URL for this repository. In the confirmation screen look for the section `Configuration File Detected` and click `Run File`. #### Mac, Linux, & Windows without Visual Studio - Install the latest [.NET 9 SDK](https://dot.net/download?cid=eshop) Or - Run the following commands in a Powershell & Terminal running as `Administrator` to automatically configuration your environment with the required tools to build and run this application. (Note: A restart is required after running the script below.) ##### Install Visual Studio Code and related extensions ```powershell install-Module -Name Microsoft.WinGet.Configuration -AllowPrerelease -AcceptLicense -Force $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") get-WinGetConfiguration -file .\.configurations\vscode.dsc.yaml | Invoke-WinGetConfiguration -AcceptConfigurationAgreements ``` > Note: These commands may require `sudo` - Optional: Install [Visual Studio Code with C# Dev Kit](https://code.visualstudio.com/docs/csharp/get-started) - Optional: Install [.NET MAUI Workload](https://learn.microsoft.com/dotnet/maui/get-started/installation?tabs=visual-studio-code) > Note: When running on Mac with Apple Silicon (M series processor), Rosetta 2 for grpc-tools. ### Running the solution > [!WARNING] > Remember to ensure that Docker is started * (Windows only) Run the application from Visual Studio: - Open the `eShop.Web.slnf` file in Visual Studio - Ensure that `eShop.AppHost.csproj` is your startup project - Hit Ctrl-F5 to launch Aspire * Or run the application from your terminal: ```powershell dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj ``` then look for lines like this in the console output in order to find the URL to open the Aspire dashboard: ```sh Login to the dashboard at: http://localhost:19888/login?t=uniquelogincodeforyou ``` > You may need to install ASP.NET Core HTTPS development certificates first, and then close all browser tabs. Learn more at https://aka.ms/aspnet/https-trust-dev-cert ### Azure Open AI When using Azure OpenAI, inside *eShop.AppHost/appsettings.json*, add the following section: ```json "ConnectionStrings": { "OpenAi": "Endpoint=xxx;Key=xxx;" } ``` Replace the values with your own. Then, in the eShop.AppHost *Program.cs*, set this value to **true** ```csharp bool useOpenAI = false; ``` Here's additional guidance on the [.NET Aspire OpenAI component](https://learn.microsoft.com/dotnet/aspire/azureai/azureai-openai-component?tabs=dotnet-cli). ### Use Azure Developer CLI You can use the [Azure Developer CLI](https://aka.ms/azd) to run this project on Azure with only a few commands. Follow the next instructions: - Install the latest or update to the latest [Azure Developer CLI (azd)](https://aka.ms/azure-dev/install). - Log in `azd` (if you haven't done it before) to your Azure account: ```sh azd auth login ``` - Initialize `azd` from the root of the repo. ```sh azd init ``` - During init: - Select `Use code in the current directory`. Azd will automatically detect the .NET Aspire project. - Confirm `.NET (Aspire)` and continue. - Select which services to expose to the Internet (exposing `webapp` is enough to test the sample). - Finalize the initialization by giving a name to your environment. - Create Azure resources and deploy the sample by running: ```sh azd up ``` Notes: - The operation takes a few minutes the first time it is ever run for an environment. - At the end of the process, `azd` will display the `url` for the webapp. Follow that link to test the sample. - You can run `azd up` after saving changes to the sample to re-deploy and update the sample. - Report any issues to [azure-dev](https://github.com/Azure/azure-dev/issues) repo. - [FAQ and troubleshoot](https://learn.microsoft.com/azure/developer/azure-developer-cli/troubleshoot?tabs=Browser) for azd. ## Contributing For more information on contributing to this repo, read [the contribution documentation](./CONTRIBUTING.md) and [the Code of Conduct](CODE-OF-CONDUCT.md). ### Sample data The sample catalog data is defined in [catalog.json](https://github.com/dotnet/eShop/blob/main/src/Catalog.API/Setup/catalog.json). Those product names, descriptions, and brand names are fictional and were generated using [GPT-35-Turbo](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/chatgpt), and the corresponding [product images](https://github.com/dotnet/eShop/tree/main/src/Catalog.API/Pics) were generated using [DALL·E 3](https://openai.com/dall-e-3). ## eShop on Azure For a version of this app configured for deployment on Azure, please view [the eShop on Azure](https://github.com/Azure-Samples/eShopOnAzure) repo. ================================================ FILE: build/acr-build/queue-all.ps1 ================================================ Param( [parameter(Mandatory=$false)][string]$acrName, [parameter(Mandatory=$false)][string]$gitUser, [parameter(Mandatory=$false)][string]$repoName="eShopOnContainers", [parameter(Mandatory=$false)][string]$gitBranch="dev", [parameter(Mandatory=$true)][string]$patToken ) $gitContext = "https://github.com/$gitUser/$repoName" $services = @( @{ Name="eshopbasket"; Image="eshop/basket.api"; File="src/Services/Basket/Basket.API/Dockerfile" }, @{ Name="eshopcatalog"; Image="eshop/catalog.api"; File="src/Services/Catalog/Catalog.API/Dockerfile" }, @{ Name="eshopidentity"; Image="eshop/identity.api"; File="src/Services/Identity/Identity.API/Dockerfile" }, @{ Name="eshopordering"; Image="eshop/ordering.api"; File="src/Services/Ordering/Ordering.API/Dockerfile" }, @{ Name="eshoporderingbg"; Image="eshop/orderprocessor"; File="src/Services/Ordering/OrderProcessor/Dockerfile" }, @{ Name="eshopwebspa"; Image="eshop/webspa"; File="src/Web/WebSPA/Dockerfile" }, @{ Name="eshopwebmvc"; Image="eshop/webmvc"; File="src/Web/WebMVC/Dockerfile" }, @{ Name="eshopwebstatus"; Image="eshop/webstatus"; File="src/Web/WebStatus/Dockerfile" }, @{ Name="eshoppayment"; Image="eshop/paymentprocessor"; File="src/Services/Payment/PaymentProcessor/Dockerfile" }, @{ Name="eshopocelotapigw"; Image="eshop/ocelotapigw"; File="src/ApiGateways/ApiGw-Base/Dockerfile" }, @{ Name="eshopmobileshoppingagg"; Image="eshop/mobileshoppingagg"; File="src/ApiGateways/Mobile.Bff.Shopping/aggregator/Dockerfile" }, @{ Name="eshopwebshoppingagg"; Image="eshop/webshoppingagg"; File="src/ApiGateways/Web.Bff.Shopping/aggregator/Dockerfile" }, @{ Name="eshoporderingsignalrhub"; Image="eshop/ordering.signalrhub"; File="src/Services/Ordering/Ordering.SignalrHub/Dockerfile" } ) $services |% { $bname = $_.Name $bimg = $_.Image $bfile = $_.File Write-Host "Setting ACR build $bname ($bimg)" az acr build-task create --registry $acrName --name $bname --image ${bimg}:$gitBranch --context $gitContext --branch $gitBranch --git-access-token $patToken --file $bfile } ================================================ FILE: build/multiarch-manifests/create-manifests.ps1 ================================================ Param( [parameter(Mandatory=$true)][string]$registry ) if ([String]::IsNullOrEmpty($registry)) { Write-Host "Registry must be set to docker registry to use" -ForegroundColor Red exit 1 } Write-Host "This script creates the local manifests, for pushing the multi-arch manifests" -ForegroundColor Yellow Write-Host "Tags used are linux-master, win-master, linux-dev, win-dev, linux-latest, win-latest" -ForegroundColor Yellow Write-Host "Multiarch images tags will be master, dev, latest" -ForegroundColor Yellow $services = "identity.api", "basket.api", "catalog.api", "ordering.api", "orderprocessor", "paymentprocessor", "webhooks.api", "ocelotapigw", "mobileshoppingagg", "webshoppingagg", "ordering.signalrhub", "webstatus", "webspa", "webmvc", "webhooks.client" foreach ($svc in $services) { Write-Host "Creating manifest for $svc and tags :latest, :master, and :dev" docker manifest create $registry/${svc}:master $registry/${svc}:linux-master $registry/${svc}:win-master docker manifest create $registry/${svc}:dev $registry/${svc}:linux-dev $registry/${svc}:win-dev docker manifest create $registry/${svc}:latest $registry/${svc}:linux-latest $registry/${svc}:win-latest Write-Host "Pushing manifest for $svc and tags :latest, :master, and :dev" docker manifest push $registry/${svc}:latest docker manifest push $registry/${svc}:dev docker manifest push $registry/${svc}:master } ================================================ FILE: ci.yml ================================================ # Configure which branches trigger builds trigger: batch: true branches: include: - main variables: - name: TeamName value: dotnet-aspire resources: repositories: # Repo: 1ESPipelineTemplates/1ESPipelineTemplates - repository: 1esPipelines type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release extends: template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines parameters: sdl: policheck: enabled: true tsa: enabled: true pool: name: NetCore1ESPool-Svc-Internal image: windows.vs2019.amd64 os: windows stages: - stage: buildStage displayName: Build Stage jobs: - job: Build displayName: Windows Build timeoutInMinutes: 90 workspace: clean: all steps: - task: UseDotNet@2 inputs: useGlobalJson: true - script: dotnet build eShop.Web.slnf displayName: Build Step ================================================ FILE: e2e/AddItemTest.spec.ts ================================================ import { test, expect } from '@playwright/test'; test('Add item to the cart', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); await page.getByRole('link', { name: 'Adventurer GPS Watch' }).click(); await page.getByRole('button', { name: 'Add to shopping bag' }).click(); await page.getByRole('link', { name: 'shopping bag' }).click(); await page.getByRole('heading', { name: 'Shopping bag' }).click(); await page.getByText('Total').nth(1).click(); await page.getByLabel('product quantity').getByText('1'); await expect.poll(() => page.getByLabel('product quantity').count()).toBeGreaterThan(0); }); ================================================ FILE: e2e/BrowseItemTest.spec.ts ================================================ import { test, expect } from '@playwright/test'; test('Browse Items', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); await page.getByRole('link', { name: 'Adventurer GPS Watch' }).click(); await page.getByRole('heading', { name: 'Adventurer GPS Watch' }).click(); //Expect await expect(page.getByRole('heading', { name: 'Adventurer GPS Watch' })).toBeVisible(); }); ================================================ FILE: e2e/RemoveItemTest.spec.ts ================================================ import { test, expect } from '@playwright/test'; test('Remove item from cart', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); await page.getByRole('link', { name: 'Adventurer GPS Watch' }).click(); await expect(page.getByRole('heading', { name: 'Adventurer GPS Watch' })).toBeVisible(); await page.getByRole('button', { name: 'Add to shopping bag' }).click(); await page.getByRole('link', { name: 'shopping bag' }).click(); await expect(page.getByRole('heading', { name: 'Shopping bag' })).toBeVisible(); await expect.poll(() => page.getByLabel('product quantity').count()).toBeGreaterThan(0); await page.getByLabel('product quantity').fill('0'); await page.getByRole('button', { name: 'Update' }).click(); await expect(page.getByText('Your shopping bag is empty')).toBeVisible(); }); ================================================ FILE: e2e/login.setup.ts ================================================ import { test as setup, expect } from '@playwright/test'; import { STORAGE_STATE } from '../playwright.config'; import { assert } from 'console'; assert(process.env.USERNAME1, 'USERNAME1 is not set'); assert(process.env.PASSWORD, 'PASSWORD is not set'); setup('Login', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); await page.getByLabel('Sign in').click(); await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); await page.getByPlaceholder('Username').fill(process.env.USERNAME1!); await page.getByPlaceholder('Password').fill(process.env.PASSWORD!); await page.getByRole('button', { name: 'Login' }).click(); await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); await page.context().storageState({ path: STORAGE_STATE }); }) ================================================ FILE: eShop.Web.slnf ================================================ { "solution": { "path": "eShop.slnx", "projects": [ "src\\Basket.API\\Basket.API.csproj", "src\\Catalog.API\\Catalog.API.csproj", "src\\EventBusRabbitMQ\\EventBusRabbitMQ.csproj", "src\\EventBus\\EventBus.csproj", "src\\Identity.API\\Identity.API.csproj", "src\\IntegrationEventLogEF\\IntegrationEventLogEF.csproj", "src\\Ordering.API\\Ordering.API.csproj", "src\\OrderProcessor\\OrderProcessor.csproj", "src\\Ordering.Domain\\Ordering.Domain.csproj", "src\\Ordering.Infrastructure\\Ordering.Infrastructure.csproj", "src\\PaymentProcessor\\PaymentProcessor.csproj", "src\\WebAppComponents\\WebAppComponents.csproj", "src\\WebApp\\WebApp.csproj", "src\\WebhookClient\\WebhookClient.csproj", "src\\Webhooks.API\\Webhooks.API.csproj", "src\\eShop.AppHost\\eShop.AppHost.csproj", "src\\eShop.ServiceDefaults\\eShop.ServiceDefaults.csproj", "tests\\Basket.UnitTests\\Basket.UnitTests.csproj", "tests\\Catalog.FunctionalTests\\Catalog.FunctionalTests.csproj", "tests\\Ordering.FunctionalTests\\Ordering.FunctionalTests.csproj", "tests\\Ordering.UnitTests\\Ordering.UnitTests.csproj" ] } } ================================================ FILE: eShop.slnx ================================================  ================================================ FILE: es-metadata.yml ================================================ schemaVersion: 0.0.1 isProduction: true accountableOwners: service: 4db45fa9-fb0f-43ce-b523-ad1da773dfbc routing: defaultAreaPath: org: devdiv path: DevDiv\ASP.NET Core ================================================ FILE: global.json ================================================ { "sdk": { "version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": true }, "test": { "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { "MSTest.Sdk": "4.0.2" } } ================================================ FILE: nuget.config ================================================  ================================================ FILE: package.json ================================================ { "name": "eshop", "version": "1.0.0", "description": "A reference .NET application implementing an eCommerce web site using a services-based architecture.", "directories": { "test": "tests" }, "scripts": {}, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@playwright/test": "^1.42.1", "@types/node": "^20.11.25", "dotenv": "^16.4.5" } } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; require("dotenv").config({ path: "./.env" }); import path from 'path'; export const STORAGE_STATE = path.join(__dirname, 'playwright/.auth/user.json'); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:5045', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', ...devices['Desktop Chrome'], }, /* Configure projects for major browsers */ projects: [ { name: 'setup', testMatch: '**/*.setup.ts', }, { name: 'e2e tests logged in', testMatch: ['**/AddItemTest.spec.ts', '**/RemoveItemTest.spec.ts'], dependencies: ['setup'], use: { storageState: STORAGE_STATE, }, }, { name: 'e2e tests without logged in', testMatch: ['**/BrowseItemTest.spec.ts'], } // { // name: 'chromium', // use: { ...devices['Desktop Chrome'] }, // }, // { // name: 'firefox', // use: { ...devices['Desktop Firefox'] }, // }, // { // name: 'webkit', // use: { ...devices['Desktop Safari'] }, // }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, // }, /* Test against branded browsers. */ // { // name: 'Microsoft Edge', // use: { ...devices['Desktop Edge'], channel: 'msedge' }, // }, // { // name: 'Google Chrome', // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // }, ], /* Run your local dev server before starting the tests */ webServer: { command: 'dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj', url: 'http://localhost:5045', reuseExistingServer: !process.env.CI, stderr: 'pipe', stdout: 'pipe', timeout: process.env.CI ? (5 * 60_000) : 60_000, }, }); ================================================ FILE: src/Basket.API/Basket.API.csproj ================================================  net10.0 2964ec8e-0d48-4541-b305-94cab537f867 true ================================================ FILE: src/Basket.API/Extensions/Extensions.cs ================================================ using System.Text.Json.Serialization; using eShop.Basket.API.Repositories; using eShop.Basket.API.IntegrationEvents.EventHandling; using eShop.Basket.API.IntegrationEvents.EventHandling.Events; namespace eShop.Basket.API.Extensions; public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddDefaultAuthentication(); builder.AddRedisClient("redis"); builder.Services.AddSingleton(); builder.AddRabbitMqEventBus("eventbus") .AddSubscription() .ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default)); } } [JsonSerializable(typeof(OrderStartedIntegrationEvent))] partial class IntegrationEventContext : JsonSerializerContext { } ================================================ FILE: src/Basket.API/Extensions/ServerCallContextIdentityExtensions.cs ================================================ #nullable enable namespace eShop.Basket.API.Extensions; internal static class ServerCallContextIdentityExtensions { public static string? GetUserIdentity(this ServerCallContext context) => context.GetHttpContext().User.FindFirst("sub")?.Value; public static string? GetUserName(this ServerCallContext context) => context.GetHttpContext().User.FindFirst(x => x.Type == ClaimTypes.Name)?.Value; } ================================================ FILE: src/Basket.API/GlobalUsings.cs ================================================ global using System.ComponentModel.DataAnnotations; global using System.Security.Claims; global using System.Text.Json; global using Grpc.Core; global using Microsoft.AspNetCore.Authorization; global using eShop.Basket.API.Extensions; global using eShop.Basket.API.Grpc; global using eShop.EventBus.Abstractions; global using eShop.EventBus.Events; global using eShop.ServiceDefaults; global using StackExchange.Redis; ================================================ FILE: src/Basket.API/Grpc/BasketService.cs ================================================ using System.Diagnostics.CodeAnalysis; using eShop.Basket.API.Repositories; using eShop.Basket.API.Extensions; using eShop.Basket.API.Model; namespace eShop.Basket.API.Grpc; public class BasketService( IBasketRepository repository, ILogger logger) : Basket.BasketBase { [AllowAnonymous] public override async Task GetBasket(GetBasketRequest request, ServerCallContext context) { var userId = context.GetUserIdentity(); if (string.IsNullOrEmpty(userId)) { return new(); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("Begin GetBasketById call from method {Method} for basket id {Id}", context.Method, userId); } var data = await repository.GetBasketAsync(userId); if (data is not null) { return MapToCustomerBasketResponse(data); } return new(); } public override async Task UpdateBasket(UpdateBasketRequest request, ServerCallContext context) { var userId = context.GetUserIdentity(); if (string.IsNullOrEmpty(userId)) { ThrowNotAuthenticated(); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("Begin UpdateBasket call from method {Method} for basket id {Id}", context.Method, userId); } var customerBasket = MapToCustomerBasket(userId, request); var response = await repository.UpdateBasketAsync(customerBasket); if (response is null) { ThrowBasketDoesNotExist(userId); } return MapToCustomerBasketResponse(response); } public override async Task DeleteBasket(DeleteBasketRequest request, ServerCallContext context) { var userId = context.GetUserIdentity(); if (string.IsNullOrEmpty(userId)) { ThrowNotAuthenticated(); } await repository.DeleteBasketAsync(userId); return new(); } [DoesNotReturn] private static void ThrowNotAuthenticated() => throw new RpcException(new Status(StatusCode.Unauthenticated, "The caller is not authenticated.")); [DoesNotReturn] private static void ThrowBasketDoesNotExist(string userId) => throw new RpcException(new Status(StatusCode.NotFound, $"Basket with buyer id {userId} does not exist")); private static CustomerBasketResponse MapToCustomerBasketResponse(CustomerBasket customerBasket) { var response = new CustomerBasketResponse(); foreach (var item in customerBasket.Items) { response.Items.Add(new BasketItem() { ProductId = item.ProductId, Quantity = item.Quantity, }); } return response; } private static CustomerBasket MapToCustomerBasket(string userId, UpdateBasketRequest customerBasketRequest) { var response = new CustomerBasket { BuyerId = userId }; foreach (var item in customerBasketRequest.Items) { response.Items.Add(new() { ProductId = item.ProductId, Quantity = item.Quantity, }); } return response; } } ================================================ FILE: src/Basket.API/IntegrationEvents/EventHandling/OrderStartedIntegrationEventHandler.cs ================================================ using eShop.Basket.API.Repositories; using eShop.Basket.API.IntegrationEvents.EventHandling.Events; namespace eShop.Basket.API.IntegrationEvents.EventHandling; public class OrderStartedIntegrationEventHandler( IBasketRepository repository, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStartedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await repository.DeleteBasketAsync(@event.UserId); } } ================================================ FILE: src/Basket.API/IntegrationEvents/Events/OrderStartedIntegrationEvent.cs ================================================ namespace eShop.Basket.API.IntegrationEvents.EventHandling.Events; // Integration Events notes: // An Event is "something that has happened in the past", therefore its name has to be // An Integration Event is an event that can cause side effects to other microservices, Bounded-Contexts or external systems. public record OrderStartedIntegrationEvent(string UserId) : IntegrationEvent; ================================================ FILE: src/Basket.API/Model/BasketItem.cs ================================================ namespace eShop.Basket.API.Model; public class BasketItem : IValidatableObject { public string Id { get; set; } public int ProductId { get; set; } public string ProductName { get; set; } public decimal UnitPrice { get; set; } public decimal OldUnitPrice { get; set; } public int Quantity { get; set; } public string PictureUrl { get; set; } public IEnumerable Validate(ValidationContext validationContext) { var results = new List(); if (Quantity < 1) { results.Add(new ValidationResult("Invalid number of units", new[] { "Quantity" })); } return results; } } ================================================ FILE: src/Basket.API/Model/CustomerBasket.cs ================================================ namespace eShop.Basket.API.Model; public class CustomerBasket { public string BuyerId { get; set; } public List Items { get; set; } = []; public CustomerBasket() { } public CustomerBasket(string customerId) { BuyerId = customerId; } } ================================================ FILE: src/Basket.API/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddBasicServiceDefaults(); builder.AddApplicationServices(); builder.Services.AddGrpc(); var app = builder.Build(); app.MapDefaultEndpoints(); app.MapGrpcService(); app.Run(); ================================================ FILE: src/Basket.API/Properties/launchSettings.json ================================================ { "profiles": { "http": { "commandName": "Project", "launchBrowser": true, "applicationUrl": "http://localhost:5221", "environmentVariables": { "Identity__Url": "http://localhost:5223", "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/Basket.API/Proto/basket.proto ================================================ syntax = "proto3"; option csharp_namespace = "eShop.Basket.API.Grpc"; package BasketApi; service Basket { rpc GetBasket(GetBasketRequest) returns (CustomerBasketResponse) {} rpc UpdateBasket(UpdateBasketRequest) returns (CustomerBasketResponse) {} rpc DeleteBasket(DeleteBasketRequest) returns (DeleteBasketResponse) {} } message GetBasketRequest { } message CustomerBasketResponse { repeated BasketItem items = 1; } message BasketItem { int32 product_id = 2; int32 quantity = 6; } message UpdateBasketRequest { repeated BasketItem items = 2; } message DeleteBasketRequest { } message DeleteBasketResponse { } ================================================ FILE: src/Basket.API/Repositories/IBasketRepository.cs ================================================ using eShop.Basket.API.Model; namespace eShop.Basket.API.Repositories; public interface IBasketRepository { Task GetBasketAsync(string customerId); Task UpdateBasketAsync(CustomerBasket basket); Task DeleteBasketAsync(string id); } ================================================ FILE: src/Basket.API/Repositories/RedisBasketRepository.cs ================================================ using System.Text.Json.Serialization; using eShop.Basket.API.Model; namespace eShop.Basket.API.Repositories; public class RedisBasketRepository(ILogger logger, IConnectionMultiplexer redis) : IBasketRepository { private readonly IDatabase _database = redis.GetDatabase(); // implementation: // - /basket/{id} "string" per unique basket private static RedisKey BasketKeyPrefix = "/basket/"u8.ToArray(); // note on UTF8 here: library limitation (to be fixed) - prefixes are more efficient as blobs private static RedisKey GetBasketKey(string userId) => BasketKeyPrefix.Append(userId); public async Task DeleteBasketAsync(string id) { return await _database.KeyDeleteAsync(GetBasketKey(id)); } public async Task GetBasketAsync(string customerId) { using var data = await _database.StringGetLeaseAsync(GetBasketKey(customerId)); if (data is null || data.Length == 0) { return null; } return JsonSerializer.Deserialize(data.Span, BasketSerializationContext.Default.CustomerBasket); } public async Task UpdateBasketAsync(CustomerBasket basket) { var json = JsonSerializer.SerializeToUtf8Bytes(basket, BasketSerializationContext.Default.CustomerBasket); var created = await _database.StringSetAsync(GetBasketKey(basket.BuyerId), json); if (!created) { logger.LogInformation("Problem occurred persisting the item."); return null; } logger.LogInformation("Basket item persisted successfully."); return await GetBasketAsync(basket.BuyerId); } } [JsonSerializable(typeof(CustomerBasket))] [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] public partial class BasketSerializationContext : JsonSerializerContext { } ================================================ FILE: src/Basket.API/appsettings.Development.json ================================================ { } ================================================ FILE: src/Basket.API/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "Kestrel": { "EndpointDefaults": { "Protocols": "Http2" } }, "ConnectionStrings": { "Redis": "localhost", "EventBus": "amqp://localhost" }, "Identity": { "Audience": "basket" }, "EventBus": { "SubscriptionClientName": "Basket" } } ================================================ FILE: src/Catalog.API/Apis/CatalogApi.cs ================================================ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Pgvector.EntityFrameworkCore; namespace eShop.Catalog.API; public static class CatalogApi { public static IEndpointRouteBuilder MapCatalogApi(this IEndpointRouteBuilder app) { // RouteGroupBuilder for catalog endpoints var vApi = app.NewVersionedApi("Catalog"); var api = vApi.MapGroup("api/catalog").HasApiVersion(1, 0).HasApiVersion(2, 0); var v1 = vApi.MapGroup("api/catalog").HasApiVersion(1, 0); var v2 = vApi.MapGroup("api/catalog").HasApiVersion(2, 0); // Routes for querying catalog items. v1.MapGet("/items", GetAllItemsV1) .WithName("ListItems") .WithSummary("List catalog items") .WithDescription("Get a paginated list of items in the catalog.") .WithTags("Items"); v2.MapGet("/items", GetAllItems) .WithName("ListItems-V2") .WithSummary("List catalog items") .WithDescription("Get a paginated list of items in the catalog.") .WithTags("Items"); api.MapGet("/items/by", GetItemsByIds) .WithName("BatchGetItems") .WithSummary("Batch get catalog items") .WithDescription("Get multiple items from the catalog") .WithTags("Items"); api.MapGet("/items/{id:int}", GetItemById) .WithName("GetItem") .WithSummary("Get catalog item") .WithDescription("Get an item from the catalog") .WithTags("Items"); v1.MapGet("/items/by/{name:minlength(1)}", GetItemsByName) .WithName("GetItemsByName") .WithSummary("Get catalog items by name") .WithDescription("Get a paginated list of catalog items with the specified name.") .WithTags("Items"); api.MapGet("/items/{id:int}/pic", GetItemPictureById) .WithName("GetItemPicture") .WithSummary("Get catalog item picture") .WithDescription("Get the picture for a catalog item") .WithTags("Items"); // Routes for resolving catalog items using AI. v1.MapGet("/items/withsemanticrelevance/{text:minlength(1)}", GetItemsBySemanticRelevanceV1) .WithName("GetRelevantItems") .WithSummary("Search catalog for relevant items") .WithDescription("Search the catalog for items related to the specified text") .WithTags("Search"); // Routes for resolving catalog items using AI. v2.MapGet("/items/withsemanticrelevance", GetItemsBySemanticRelevance) .WithName("GetRelevantItems-V2") .WithSummary("Search catalog for relevant items") .WithDescription("Search the catalog for items related to the specified text") .WithTags("Search"); // Routes for resolving catalog items by type and brand. v1.MapGet("/items/type/{typeId}/brand/{brandId?}", GetItemsByBrandAndTypeId) .WithName("GetItemsByTypeAndBrand") .WithSummary("Get catalog items by type and brand") .WithDescription("Get catalog items of the specified type and brand") .WithTags("Types"); v1.MapGet("/items/type/all/brand/{brandId:int?}", GetItemsByBrandId) .WithName("GetItemsByBrand") .WithSummary("List catalog items by brand") .WithDescription("Get a list of catalog items for the specified brand") .WithTags("Brands"); api.MapGet("/catalogtypes", [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] async (CatalogContext context) => await context.CatalogTypes.OrderBy(x => x.Type).ToListAsync()) .WithName("ListItemTypes") .WithSummary("List catalog item types") .WithDescription("Get a list of the types of catalog items") .WithTags("Types"); api.MapGet("/catalogbrands", [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] async (CatalogContext context) => await context.CatalogBrands.OrderBy(x => x.Brand).ToListAsync()) .WithName("ListItemBrands") .WithSummary("List catalog item brands") .WithDescription("Get a list of the brands of catalog items") .WithTags("Brands"); // Routes for modifying catalog items. v1.MapPut("/items", UpdateItemV1) .WithName("UpdateItem") .WithSummary("Create or replace a catalog item") .WithDescription("Create or replace a catalog item") .WithTags("Items"); v2.MapPut("/items/{id:int}", UpdateItem) .WithName("UpdateItem-V2") .WithSummary("Create or replace a catalog item") .WithDescription("Create or replace a catalog item") .WithTags("Items"); api.MapPost("/items", CreateItem) .WithName("CreateItem") .WithSummary("Create a catalog item") .WithDescription("Create a new item in the catalog"); api.MapDelete("/items/{id:int}", DeleteItemById) .WithName("DeleteItem") .WithSummary("Delete catalog item") .WithDescription("Delete the specified catalog item"); return app; } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetAllItemsV1( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services) { return await GetAllItems(paginationRequest, services, null, null, null); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetAllItems( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The name of the item to return")] string? name, [Description("The type of items to return")] int? type, [Description("The brand of items to return")] int? brand) { var pageSize = paginationRequest.PageSize; var pageIndex = paginationRequest.PageIndex; var root = (IQueryable)services.Context.CatalogItems; if (name is not null) { root = root.Where(c => c.Name.StartsWith(name)); } if (type is not null) { root = root.Where(c => c.CatalogTypeId == type); } if (brand is not null) { root = root.Where(c => c.CatalogBrandId == brand); } var totalItems = await root .LongCountAsync(); var itemsOnPage = await root .OrderBy(c => c.Name) .Skip(pageSize * pageIndex) .Take(pageSize) .ToListAsync(); return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetItemsByIds( [AsParameters] CatalogServices services, [Description("List of ids for catalog items to return")] int[] ids) { var items = await services.Context.CatalogItems.Where(item => ids.Contains(item.Id)).ToListAsync(); return TypedResults.Ok(items); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task, NotFound, BadRequest>> GetItemById( HttpContext httpContext, [AsParameters] CatalogServices services, [Description("The catalog item id")] int id) { if (id <= 0) { return TypedResults.BadRequest(new (){ Detail = "Id is not valid" }); } var item = await services.Context.CatalogItems.Include(ci => ci.CatalogBrand).SingleOrDefaultAsync(ci => ci.Id == id); if (item == null) { return TypedResults.NotFound(); } return TypedResults.Ok(item); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetItemsByName( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The name of the item to return")] string name) { return await GetAllItems(paginationRequest, services, name, null, null); } [ProducesResponseType(StatusCodes.Status200OK, "application/octet-stream", [ "image/png", "image/gif", "image/jpeg", "image/bmp", "image/tiff", "image/wmf", "image/jp2", "image/svg+xml", "image/webp" ])] public static async Task> GetItemPictureById( CatalogContext context, IWebHostEnvironment environment, [Description("The catalog item id")] int id) { var item = await context.CatalogItems.FindAsync(id); if (item is null || item.PictureFileName is null) { return TypedResults.NotFound(); } var path = GetFullPath(environment.ContentRootPath, item.PictureFileName); string imageFileExtension = Path.GetExtension(item.PictureFileName) ?? string.Empty; string mimetype = GetImageMimeTypeFromImageFileExtension(imageFileExtension); DateTime lastModified = File.GetLastWriteTimeUtc(path); return TypedResults.PhysicalFile(path, mimetype, lastModified: lastModified); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>, RedirectToRouteHttpResult>> GetItemsBySemanticRelevanceV1( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The text string to use when search for related items in the catalog")] string text) { return await GetItemsBySemanticRelevance(paginationRequest, services, text); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>, RedirectToRouteHttpResult>> GetItemsBySemanticRelevance( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The text string to use when search for related items in the catalog"), Required, MinLength(1)] string text) { var pageSize = paginationRequest.PageSize; var pageIndex = paginationRequest.PageIndex; if (!services.CatalogAI.IsEnabled) { return await GetItemsByName(paginationRequest, services, text); } // Create an embedding for the input search var vector = await services.CatalogAI.GetEmbeddingAsync(text); if (vector is null) { return await GetItemsByName(paginationRequest, services, text); } // Get the total number of items var totalItems = await services.Context.CatalogItems .LongCountAsync(); // Get the next page of items, ordered by most similar (smallest distance) to the input search List itemsOnPage; if (services.Logger.IsEnabled(LogLevel.Debug)) { var itemsWithDistance = await services.Context.CatalogItems .Where(c => c.Embedding != null) .Select(c => new { Item = c, Distance = c.Embedding!.CosineDistance(vector) }) .OrderBy(c => c.Distance) .Skip(pageSize * pageIndex) .Take(pageSize) .ToListAsync(); services.Logger.LogDebug("Results from {text}: {results}", text, string.Join(", ", itemsWithDistance.Select(i => $"{i.Item.Name} => {i.Distance}"))); itemsOnPage = itemsWithDistance.Select(i => i.Item).ToList(); } else { itemsOnPage = await services.Context.CatalogItems .Where(c => c.Embedding != null) .OrderBy(c => c.Embedding!.CosineDistance(vector)) .Skip(pageSize * pageIndex) .Take(pageSize) .ToListAsync(); } return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetItemsByBrandAndTypeId( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The type of items to return")] int typeId, [Description("The brand of items to return")] int? brandId) { return await GetAllItems(paginationRequest, services, null, typeId, brandId); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task>> GetItemsByBrandId( [AsParameters] PaginationRequest paginationRequest, [AsParameters] CatalogServices services, [Description("The brand of items to return")] int? brandId) { return await GetAllItems(paginationRequest, services, null, null, brandId); } public static async Task, NotFound>> UpdateItemV1( HttpContext httpContext, [AsParameters] CatalogServices services, CatalogItem productToUpdate) { if (productToUpdate?.Id == null) { return TypedResults.BadRequest(new (){ Detail = "Item id must be provided in the request body." }); } return await UpdateItem(httpContext, productToUpdate.Id, services, productToUpdate); } public static async Task, NotFound>> UpdateItem( HttpContext httpContext, [Description("The id of the catalog item to delete")] int id, [AsParameters] CatalogServices services, CatalogItem productToUpdate) { var catalogItem = await services.Context.CatalogItems.SingleOrDefaultAsync(i => i.Id == id); if (catalogItem == null) { return TypedResults.NotFound(new (){ Detail = $"Item with id {id} not found." }); } // Update current product var catalogEntry = services.Context.Entry(catalogItem); catalogEntry.CurrentValues.SetValues(productToUpdate); catalogItem.Embedding = await services.CatalogAI.GetEmbeddingAsync(catalogItem); var priceEntry = catalogEntry.Property(i => i.Price); if (priceEntry.IsModified) // Save product's data and publish integration event through the Event Bus if price has changed { //Create Integration Event to be published through the Event Bus var priceChangedEvent = new ProductPriceChangedIntegrationEvent(catalogItem.Id, productToUpdate.Price, priceEntry.OriginalValue); // Achieving atomicity between original Catalog database operation and the IntegrationEventLog thanks to a local transaction await services.EventService.SaveEventAndCatalogContextChangesAsync(priceChangedEvent); // Publish through the Event Bus and mark the saved event as published await services.EventService.PublishThroughEventBusAsync(priceChangedEvent); } else // Just save the updated product because the Product's Price hasn't changed. { await services.Context.SaveChangesAsync(); } return TypedResults.Created($"/api/catalog/items/{id}"); } [ProducesResponseType(StatusCodes.Status400BadRequest, "application/problem+json")] public static async Task CreateItem( [AsParameters] CatalogServices services, CatalogItem product) { var item = new CatalogItem(product.Name) { Id = product.Id, CatalogBrandId = product.CatalogBrandId, CatalogTypeId = product.CatalogTypeId, Description = product.Description, PictureFileName = product.PictureFileName, Price = product.Price, AvailableStock = product.AvailableStock, RestockThreshold = product.RestockThreshold, MaxStockThreshold = product.MaxStockThreshold }; item.Embedding = await services.CatalogAI.GetEmbeddingAsync(item); services.Context.CatalogItems.Add(item); await services.Context.SaveChangesAsync(); return TypedResults.Created($"/api/catalog/items/{item.Id}"); } public static async Task> DeleteItemById( [AsParameters] CatalogServices services, [Description("The id of the catalog item to delete")] int id) { var item = services.Context.CatalogItems.SingleOrDefault(x => x.Id == id); if (item is null) { return TypedResults.NotFound(); } services.Context.CatalogItems.Remove(item); await services.Context.SaveChangesAsync(); return TypedResults.NoContent(); } private static string GetImageMimeTypeFromImageFileExtension(string extension) => extension switch { ".png" => "image/png", ".gif" => "image/gif", ".jpg" or ".jpeg" => "image/jpeg", ".bmp" => "image/bmp", ".tiff" => "image/tiff", ".wmf" => "image/wmf", ".jp2" => "image/jp2", ".svg" => "image/svg+xml", ".webp" => "image/webp", _ => "application/octet-stream", }; public static string GetFullPath(string contentRootPath, string pictureFileName) => Path.Combine(contentRootPath, "Pics", pictureFileName); } ================================================ FILE: src/Catalog.API/Catalog.API.csproj ================================================  net10.0 enable d1b521ec-3411-4d39-98c6-8509466ed471 $(MSBuildProjectDirectory) all runtime; build; native; contentfiles; analyzers; buildtransitive runtime; build; native; contentfiles; analyzers; buildtransitive all ================================================ FILE: src/Catalog.API/Catalog.API.http ================================================ @Catalog.API_HostAddress = http://localhost:5222 @ApiVersion = 1.0 GET {{Catalog.API_HostAddress}}/openapi/v1.json ### GET {{Catalog.API_HostAddress}}/api/catalog/items?api-version={{ApiVersion}} ### GET {{Catalog.API_HostAddress}}/api/catalog/items/type/1/brand/2?api-version={{ApiVersion}} ### # A request with an unknown API version returns a 400 ProblemDetails response GET {{Catalog.API_HostAddress}}/api/catalog/items/463/pic?api-version=99 ### # A request with an unknown item id returns a 404 NotFound with empty response body GET {{Catalog.API_HostAddress}}/api/catalog/items/463/pic?api-version={{ApiVersion}} ### PUT {{Catalog.API_HostAddress}}/api/catalog/items?api-version={{ApiVersion}} content-type: application/json { "id": 999, "name": "Item1", "price": 100, "description": "Description1", "pictureFileName": "item1.png", "catalogTypeId": 1, "catalogBrandId": 2 } ================================================ FILE: src/Catalog.API/Catalog.API.json ================================================ { "openapi": "3.1.1", "info": { "title": "eShop - Catalog HTTP API", "description": "The Catalog Microservice HTTP API. This is a Data-Driven/CRUD microservice sample", "version": "1.0" }, "paths": { "/api/catalog/items/by": { "get": { "tags": [ "Items" ], "summary": "Batch get catalog items", "description": "Get multiple items from the catalog", "operationId": "BatchGetItems", "parameters": [ { "name": "ids", "in": "query", "description": "List of ids for catalog items to return", "required": true, "schema": { "type": "array", "items": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/{id}": { "get": { "tags": [ "Items" ], "summary": "Get catalog item", "description": "Get an item from the catalog", "operationId": "GetItem", "parameters": [ { "name": "id", "in": "path", "description": "The catalog item id", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } } }, "404": { "description": "Not Found" }, "400": { "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } }, "delete": { "tags": [ "Catalog" ], "summary": "Delete catalog item", "description": "Delete the specified catalog item", "operationId": "DeleteItem", "parameters": [ { "name": "id", "in": "path", "description": "The id of the catalog item to delete", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "204": { "description": "No Content" }, "404": { "description": "Not Found" } } } }, "/api/catalog/items/{id}/pic": { "get": { "tags": [ "Items" ], "summary": "Get catalog item picture", "description": "Get the picture for a catalog item", "operationId": "GetItemPicture", "parameters": [ { "name": "id", "in": "path", "description": "The catalog item id", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "404": { "description": "Not Found" }, "200": { "description": "OK", "content": { "application/octet-stream": { "schema": { "type": "string", "format": "byte" } }, "image/png": { "schema": { "type": "string", "format": "byte" } }, "image/gif": { "schema": { "type": "string", "format": "byte" } }, "image/jpeg": { "schema": { "type": "string", "format": "byte" } }, "image/bmp": { "schema": { "type": "string", "format": "byte" } }, "image/tiff": { "schema": { "type": "string", "format": "byte" } }, "image/wmf": { "schema": { "type": "string", "format": "byte" } }, "image/jp2": { "schema": { "type": "string", "format": "byte" } }, "image/svg+xml": { "schema": { "type": "string", "format": "byte" } }, "image/webp": { "schema": { "type": "string", "format": "byte" } } } } } } }, "/api/catalog/catalogtypes": { "get": { "tags": [ "Types" ], "summary": "List catalog item types", "description": "Get a list of the types of catalog items", "operationId": "ListItemTypes", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogType" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/catalogbrands": { "get": { "tags": [ "Brands" ], "summary": "List catalog item brands", "description": "Get a list of the brands of catalog items", "operationId": "ListItemBrands", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogBrand" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items": { "post": { "tags": [ "Catalog" ], "summary": "Create a catalog item", "description": "Create a new item in the catalog", "operationId": "CreateItem", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } }, "required": true }, "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } }, "get": { "tags": [ "Items" ], "summary": "List catalog items", "description": "Get a paginated list of items in the catalog.", "operationId": "ListItems", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } }, "put": { "tags": [ "Items" ], "summary": "Create or replace a catalog item", "description": "Create or replace a catalog item", "operationId": "UpdateItem", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } }, "required": true }, "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/by/{name}": { "get": { "tags": [ "Items" ], "summary": "Get catalog items by name", "description": "Get a paginated list of catalog items with the specified name.", "operationId": "GetItemsByName", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "name", "in": "path", "description": "The name of the item to return", "required": true, "schema": { "minLength": 1, "type": "string" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/withsemanticrelevance/{text}": { "get": { "tags": [ "Search" ], "summary": "Search catalog for relevant items", "description": "Search the catalog for items related to the specified text", "operationId": "GetRelevantItems", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "text", "in": "path", "description": "The text string to use when search for related items in the catalog", "required": true, "schema": { "minLength": 1, "type": "string" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/type/{typeId}/brand/{brandId}": { "get": { "tags": [ "Types" ], "summary": "Get catalog items by type and brand", "description": "Get catalog items of the specified type and brand", "operationId": "GetItemsByTypeAndBrand", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "typeId", "in": "path", "description": "The type of items to return", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } }, { "name": "brandId", "in": "path", "description": "The brand of items to return", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/type/all/brand/{brandId}": { "get": { "tags": [ "Brands" ], "summary": "List catalog items by brand", "description": "Get a list of catalog items for the specified brand", "operationId": "GetItemsByBrand", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "brandId", "in": "path", "description": "The brand of items to return", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "1.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } } }, "components": { "schemas": { "CatalogBrand": { "required": [ "brand" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "brand": { "type": "string" } } }, "CatalogItem": { "required": [ "name" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "name": { "type": "string" }, "description": { "type": [ "null", "string" ] }, "price": { "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", "type": [ "number", "string" ], "format": "double" }, "pictureFileName": { "type": [ "null", "string" ] }, "catalogTypeId": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "catalogType": { "oneOf": [ { "type": "null" }, { "$ref": "#/components/schemas/CatalogType" } ] }, "catalogBrandId": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "catalogBrand": { "oneOf": [ { "type": "null" }, { "$ref": "#/components/schemas/CatalogBrand" } ] }, "availableStock": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "restockThreshold": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "maxStockThreshold": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "onReorder": { "type": "boolean" } } }, "CatalogType": { "required": [ "type" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "type": { "type": "string" } } }, "PaginatedItemsOfCatalogItem": { "required": [ "pageIndex", "pageSize", "count", "data" ], "type": "object", "properties": { "pageIndex": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "pageSize": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "count": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int64" }, "data": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } } } }, "ProblemDetails": { "type": "object", "properties": { "type": { "type": [ "null", "string" ] }, "title": { "type": [ "null", "string" ] }, "status": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "null", "integer", "string" ], "format": "int32" }, "detail": { "type": [ "null", "string" ] }, "instance": { "type": [ "null", "string" ] } } } } }, "tags": [ { "name": "Items" }, { "name": "Catalog" }, { "name": "Types" }, { "name": "Brands" }, { "name": "Search" } ] } ================================================ FILE: src/Catalog.API/Catalog.API_v2.json ================================================ { "openapi": "3.1.1", "info": { "title": "eShop - Catalog HTTP API", "description": "The Catalog Microservice HTTP API. This is a Data-Driven/CRUD microservice sample", "version": "2.0" }, "paths": { "/api/catalog/items/by": { "get": { "tags": [ "Items" ], "summary": "Batch get catalog items", "description": "Get multiple items from the catalog", "operationId": "BatchGetItems", "parameters": [ { "name": "ids", "in": "query", "description": "List of ids for catalog items to return", "required": true, "schema": { "type": "array", "items": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/{id}": { "get": { "tags": [ "Items" ], "summary": "Get catalog item", "description": "Get an item from the catalog", "operationId": "GetItem", "parameters": [ { "name": "id", "in": "path", "description": "The catalog item id", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } } }, "404": { "description": "Not Found" }, "400": { "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } }, "delete": { "tags": [ "Catalog" ], "summary": "Delete catalog item", "description": "Delete the specified catalog item", "operationId": "DeleteItem", "parameters": [ { "name": "id", "in": "path", "description": "The id of the catalog item to delete", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "204": { "description": "No Content" }, "404": { "description": "Not Found" } } }, "put": { "tags": [ "Items" ], "summary": "Create or replace a catalog item", "description": "Create or replace a catalog item", "operationId": "UpdateItem-V2", "parameters": [ { "name": "id", "in": "path", "description": "The id of the catalog item to delete", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } }, "required": true }, "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } }, "404": { "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/{id}/pic": { "get": { "tags": [ "Items" ], "summary": "Get catalog item picture", "description": "Get the picture for a catalog item", "operationId": "GetItemPicture", "parameters": [ { "name": "id", "in": "path", "description": "The catalog item id", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "404": { "description": "Not Found" }, "200": { "description": "OK", "content": { "application/octet-stream": { "schema": { "type": "string", "format": "byte" } }, "image/png": { "schema": { "type": "string", "format": "byte" } }, "image/gif": { "schema": { "type": "string", "format": "byte" } }, "image/jpeg": { "schema": { "type": "string", "format": "byte" } }, "image/bmp": { "schema": { "type": "string", "format": "byte" } }, "image/tiff": { "schema": { "type": "string", "format": "byte" } }, "image/wmf": { "schema": { "type": "string", "format": "byte" } }, "image/jp2": { "schema": { "type": "string", "format": "byte" } }, "image/svg+xml": { "schema": { "type": "string", "format": "byte" } }, "image/webp": { "schema": { "type": "string", "format": "byte" } } } } } } }, "/api/catalog/catalogtypes": { "get": { "tags": [ "Types" ], "summary": "List catalog item types", "description": "Get a list of the types of catalog items", "operationId": "ListItemTypes", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogType" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/catalogbrands": { "get": { "tags": [ "Brands" ], "summary": "List catalog item brands", "description": "Get a list of the brands of catalog items", "operationId": "ListItemBrands", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogBrand" } } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items": { "post": { "tags": [ "Catalog" ], "summary": "Create a catalog item", "description": "Create a new item in the catalog", "operationId": "CreateItem", "parameters": [ { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogItem" } } }, "required": true }, "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } }, "get": { "tags": [ "Items" ], "summary": "List catalog items", "description": "Get a paginated list of items in the catalog.", "operationId": "ListItems-V2", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "name", "in": "query", "description": "The name of the item to return", "schema": { "type": "string" } }, { "name": "type", "in": "query", "description": "The type of items to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } }, { "name": "brand", "in": "query", "description": "The brand of items to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } }, "/api/catalog/items/withsemanticrelevance": { "get": { "tags": [ "Search" ], "summary": "Search catalog for relevant items", "description": "Search the catalog for items related to the specified text", "operationId": "GetRelevantItems-V2", "parameters": [ { "name": "PageSize", "in": "query", "description": "Number of items to return in a single page of results", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 10 } }, { "name": "PageIndex", "in": "query", "description": "The index of the page of results to return", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32", "default": 0 } }, { "name": "text", "in": "query", "description": "The text string to use when search for related items in the catalog", "required": true, "schema": { "minLength": 1, "type": "string" } }, { "name": "api-version", "in": "query", "description": "The API version, in the format 'major.minor'.", "required": true, "schema": { "type": "string", "example": "2.0" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedItemsOfCatalogItem" } } } }, "400": { "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } } }, "components": { "schemas": { "CatalogBrand": { "required": [ "brand" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "brand": { "type": "string" } } }, "CatalogItem": { "required": [ "name" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "name": { "type": "string" }, "description": { "type": [ "null", "string" ] }, "price": { "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", "type": [ "number", "string" ], "format": "double" }, "pictureFileName": { "type": [ "null", "string" ] }, "catalogTypeId": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "catalogType": { "oneOf": [ { "type": "null" }, { "$ref": "#/components/schemas/CatalogType" } ] }, "catalogBrandId": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "catalogBrand": { "oneOf": [ { "type": "null" }, { "$ref": "#/components/schemas/CatalogBrand" } ] }, "availableStock": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "restockThreshold": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "maxStockThreshold": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "onReorder": { "type": "boolean" } } }, "CatalogType": { "required": [ "type" ], "type": "object", "properties": { "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "type": { "type": "string" } } }, "PaginatedItemsOfCatalogItem": { "required": [ "pageIndex", "pageSize", "count", "data" ], "type": "object", "properties": { "pageIndex": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "pageSize": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" }, "count": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int64" }, "data": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } } } }, "ProblemDetails": { "type": "object", "properties": { "type": { "type": [ "null", "string" ] }, "title": { "type": [ "null", "string" ] }, "status": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "null", "integer", "string" ], "format": "int32" }, "detail": { "type": [ "null", "string" ] }, "instance": { "type": [ "null", "string" ] } } } } }, "tags": [ { "name": "Items" }, { "name": "Catalog" }, { "name": "Types" }, { "name": "Brands" }, { "name": "Search" } ] } ================================================ FILE: src/Catalog.API/CatalogOptions.cs ================================================ namespace eShop.Catalog.API; public class CatalogOptions { public string? PicBaseUrl { get; set; } public bool UseCustomizationData { get; set; } } ================================================ FILE: src/Catalog.API/Extensions/Extensions.cs ================================================ using eShop.Catalog.API.Services; public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { // Avoid loading full database config and migrations if startup // is being invoked from build-time OpenAPI generation if (builder.Environment.IsBuild()) { builder.Services.AddDbContext(); return; } builder.AddNpgsqlDbContext("catalogdb", configureDbContextOptions: dbContextOptionsBuilder => { dbContextOptionsBuilder.UseNpgsql(builder => { builder.UseVector(); }); }); // REVIEW: This is done for development ease but shouldn't be here in production builder.Services.AddMigration(); // Add the integration services that consume the DbContext builder.Services.AddTransient>(); builder.Services.AddTransient(); builder.AddRabbitMqEventBus("eventbus") .AddSubscription() .AddSubscription(); builder.Services.AddOptions() .BindConfiguration(nameof(CatalogOptions)); if (builder.Configuration["OllamaEnabled"] is string ollamaEnabled && bool.Parse(ollamaEnabled)) { builder.AddOllamaApiClient("embedding") .AddEmbeddingGenerator(); } else if (!string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("textEmbeddingModel"))) { builder.AddOpenAIClientFromConfiguration("textEmbeddingModel") .AddEmbeddingGenerator(); } builder.Services.AddScoped(); } } ================================================ FILE: src/Catalog.API/Extensions/HostEnvironmentExtensions.cs ================================================ using System.Reflection; namespace Microsoft.Extensions.Hosting; internal static class HostEnvironmentExtensions { public static bool IsBuild(this IHostEnvironment hostEnvironment) { // Check if the environment is "Build" or the entry assembly is "GetDocument.Insider" // to account for scenarios where app is launching via OpenAPI build-time generation // via the GetDocument.Insider tool. return hostEnvironment.IsEnvironment("Build") || Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider"; } } ================================================ FILE: src/Catalog.API/GlobalUsings.cs ================================================ global using Asp.Versioning; global using Asp.Versioning.Conventions; global using eShop.Catalog.API; global using eShop.Catalog.API.Infrastructure; global using eShop.Catalog.API.Infrastructure.EntityConfigurations; global using eShop.Catalog.API.Infrastructure.Exceptions; global using eShop.Catalog.API.IntegrationEvents; global using eShop.Catalog.API.IntegrationEvents.EventHandling; global using eShop.Catalog.API.IntegrationEvents.Events; global using eShop.Catalog.API.Model; global using eShop.EventBus.Abstractions; global using eShop.EventBus.Events; global using eShop.IntegrationEventLogEF; global using eShop.IntegrationEventLogEF.Services; global using eShop.IntegrationEventLogEF.Utilities; global using eShop.ServiceDefaults; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.Extensions.Options; global using Npgsql; ================================================ FILE: src/Catalog.API/Infrastructure/CatalogContext.cs ================================================ namespace eShop.Catalog.API.Infrastructure; /// /// Add migrations using the following command inside the 'Catalog.API' project directory: /// /// dotnet ef migrations add --context CatalogContext [migration-name] /// public class CatalogContext : DbContext { public CatalogContext(DbContextOptions options, IConfiguration configuration) : base(options) { } public required DbSet CatalogItems { get; set; } public required DbSet CatalogBrands { get; set; } public required DbSet CatalogTypes { get; set; } protected override void OnModelCreating(ModelBuilder builder) { builder.HasPostgresExtension("vector"); builder.ApplyConfiguration(new CatalogBrandEntityTypeConfiguration()); builder.ApplyConfiguration(new CatalogTypeEntityTypeConfiguration()); builder.ApplyConfiguration(new CatalogItemEntityTypeConfiguration()); // Add the outbox table to this context builder.UseIntegrationEventLogs(); } } ================================================ FILE: src/Catalog.API/Infrastructure/CatalogContextSeed.cs ================================================ using System.Text.Json; using eShop.Catalog.API.Services; using Pgvector; namespace eShop.Catalog.API.Infrastructure; public partial class CatalogContextSeed( IWebHostEnvironment env, IOptions settings, ICatalogAI catalogAI, ILogger logger) : IDbSeeder { public async Task SeedAsync(CatalogContext context) { var useCustomizationData = settings.Value.UseCustomizationData; var contentRootPath = env.ContentRootPath; var picturePath = env.WebRootPath; // Workaround from https://github.com/npgsql/efcore.pg/issues/292#issuecomment-388608426 context.Database.OpenConnection(); ((NpgsqlConnection)context.Database.GetDbConnection()).ReloadTypes(); if (!context.CatalogItems.Any()) { var sourcePath = Path.Combine(contentRootPath, "Setup", "catalog.json"); var sourceJson = File.ReadAllText(sourcePath); var sourceItems = JsonSerializer.Deserialize(sourceJson) ?? Array.Empty(); context.CatalogBrands.RemoveRange(context.CatalogBrands); await context.CatalogBrands.AddRangeAsync(sourceItems.Select(x => x.Brand).Distinct() .Where(brandName => brandName != null) .Select(brandName => new CatalogBrand(brandName!))); logger.LogInformation("Seeded catalog with {NumBrands} brands", context.CatalogBrands.Count()); context.CatalogTypes.RemoveRange(context.CatalogTypes); await context.CatalogTypes.AddRangeAsync(sourceItems.Select(x => x.Type).Distinct() .Where(typeName => typeName != null) .Select(typeName => new CatalogType(typeName!))); logger.LogInformation("Seeded catalog with {NumTypes} types", context.CatalogTypes.Count()); await context.SaveChangesAsync(); var brandIdsByName = await context.CatalogBrands.ToDictionaryAsync(x => x.Brand, x => x.Id); var typeIdsByName = await context.CatalogTypes.ToDictionaryAsync(x => x.Type, x => x.Id); var catalogItems = sourceItems .Where(source => source.Name != null && source.Brand != null && source.Type != null) .Select(source => new CatalogItem(source.Name!) { Id = source.Id, Description = source.Description, Price = source.Price, CatalogBrandId = brandIdsByName[source.Brand!], CatalogTypeId = typeIdsByName[source.Type!], AvailableStock = 100, MaxStockThreshold = 200, RestockThreshold = 10, PictureFileName = $"{source.Id}.webp", }).ToArray(); if (catalogAI.IsEnabled) { logger.LogInformation("Generating {NumItems} embeddings", catalogItems.Length); IReadOnlyList? embeddings = await catalogAI.GetEmbeddingsAsync(catalogItems); for (int i = 0; i < catalogItems.Length; i++) { catalogItems[i].Embedding = embeddings?[i]; } } await context.CatalogItems.AddRangeAsync(catalogItems); logger.LogInformation("Seeded catalog with {NumItems} items", context.CatalogItems.Count()); await context.SaveChangesAsync(); } } private class CatalogSourceEntry { public int Id { get; set; } public string? Type { get; set; } public string? Brand { get; set; } public string? Name { get; set; } public string? Description { get; set; } public decimal Price { get; set; } } } ================================================ FILE: src/Catalog.API/Infrastructure/EntityConfigurations/CatalogBrandEntityTypeConfiguration.cs ================================================ namespace eShop.Catalog.API.Infrastructure.EntityConfigurations; class CatalogBrandEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("CatalogBrand"); builder.Property(cb => cb.Brand) .HasMaxLength(100); } } ================================================ FILE: src/Catalog.API/Infrastructure/EntityConfigurations/CatalogItemEntityTypeConfiguration.cs ================================================ namespace eShop.Catalog.API.Infrastructure.EntityConfigurations; class CatalogItemEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("Catalog"); builder.Property(ci => ci.Name) .HasMaxLength(50); builder.Property(ci => ci.Embedding) .HasColumnType("vector(384)"); builder.HasOne(ci => ci.CatalogBrand) .WithMany(); builder.HasOne(ci => ci.CatalogType) .WithMany(); builder.HasIndex(ci => ci.Name); } } ================================================ FILE: src/Catalog.API/Infrastructure/EntityConfigurations/CatalogTypeEntityTypeConfiguration.cs ================================================ namespace eShop.Catalog.API.Infrastructure.EntityConfigurations; class CatalogTypeEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("CatalogType"); builder.Property(cb => cb.Type) .HasMaxLength(100); } } ================================================ FILE: src/Catalog.API/Infrastructure/Exceptions/CatalogDomainException.cs ================================================ namespace eShop.Catalog.API.Infrastructure.Exceptions; /// /// Exception type for app exceptions /// public class CatalogDomainException : Exception { public CatalogDomainException() { } public CatalogDomainException(string message) : base(message) { } public CatalogDomainException(string message, Exception innerException) : base(message, innerException) { } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231009153249_Initial.Designer.cs ================================================ // using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Catalog.API.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { [DbContext(typeof(CatalogContext))] [Migration("20231009153249_Initial")] partial class Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "7.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("catalog_brand_hilo") .IncrementsBy(10); modelBuilder.HasSequence("catalog_hilo") .IncrementsBy(10); modelBuilder.HasSequence("catalog_type_hilo") .IncrementsBy(10); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogBrand", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "catalog_brand_hilo"); b.Property("Brand") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogBrand", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "catalog_hilo"); b.Property("AvailableStock") .HasColumnType("integer"); b.Property("CatalogBrandId") .HasColumnType("integer"); b.Property("CatalogTypeId") .HasColumnType("integer"); b.Property("Description") .HasColumnType("text"); b.Property("Embedding") .HasColumnType("vector(384)"); b.Property("MaxStockThreshold") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)"); b.Property("OnReorder") .HasColumnType("boolean"); b.Property("PictureFileName") .HasColumnType("text"); b.Property("Price") .HasColumnType("numeric"); b.Property("RestockThreshold") .HasColumnType("integer"); b.HasKey("Id"); b.HasIndex("CatalogBrandId"); b.HasIndex("CatalogTypeId"); b.ToTable("Catalog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogType", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "catalog_type_hilo"); b.Property("Type") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogType", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.HasOne("eShop.Catalog.API.Model.CatalogBrand", "CatalogBrand") .WithMany() .HasForeignKey("CatalogBrandId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Catalog.API.Model.CatalogType", "CatalogType") .WithMany() .HasForeignKey("CatalogTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CatalogBrand"); b.Navigation("CatalogType"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231009153249_Initial.cs ================================================ using Microsoft.EntityFrameworkCore.Migrations; using Pgvector; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { /// public partial class Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AlterDatabase() .Annotation("Npgsql:PostgresExtension:vector", ",,"); migrationBuilder.CreateSequence( name: "catalog_brand_hilo", incrementBy: 10); migrationBuilder.CreateSequence( name: "catalog_hilo", incrementBy: 10); migrationBuilder.CreateSequence( name: "catalog_type_hilo", incrementBy: 10); migrationBuilder.CreateTable( name: "CatalogBrand", columns: table => new { Id = table.Column(type: "integer", nullable: false), Brand = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) }, constraints: table => { table.PrimaryKey("PK_CatalogBrand", x => x.Id); }); migrationBuilder.CreateTable( name: "CatalogType", columns: table => new { Id = table.Column(type: "integer", nullable: false), Type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) }, constraints: table => { table.PrimaryKey("PK_CatalogType", x => x.Id); }); migrationBuilder.CreateTable( name: "Catalog", columns: table => new { Id = table.Column(type: "integer", nullable: false), Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), Description = table.Column(type: "text", nullable: true), Price = table.Column(type: "numeric", nullable: false), PictureFileName = table.Column(type: "text", nullable: true), CatalogTypeId = table.Column(type: "integer", nullable: false), CatalogBrandId = table.Column(type: "integer", nullable: false), AvailableStock = table.Column(type: "integer", nullable: false), RestockThreshold = table.Column(type: "integer", nullable: false), MaxStockThreshold = table.Column(type: "integer", nullable: false), Embedding = table.Column(type: "vector(384)", nullable: true), OnReorder = table.Column(type: "boolean", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Catalog", x => x.Id); table.ForeignKey( name: "FK_Catalog_CatalogBrand_CatalogBrandId", column: x => x.CatalogBrandId, principalTable: "CatalogBrand", principalColumn: "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_Catalog_CatalogType_CatalogTypeId", column: x => x.CatalogTypeId, principalTable: "CatalogType", principalColumn: "Id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( name: "IX_Catalog_CatalogBrandId", table: "Catalog", column: "CatalogBrandId"); migrationBuilder.CreateIndex( name: "IX_Catalog_CatalogTypeId", table: "Catalog", column: "CatalogTypeId"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "Catalog"); migrationBuilder.DropTable( name: "CatalogBrand"); migrationBuilder.DropTable( name: "CatalogType"); migrationBuilder.DropSequence( name: "catalog_brand_hilo"); migrationBuilder.DropSequence( name: "catalog_hilo"); migrationBuilder.DropSequence( name: "catalog_type_hilo"); } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231018163051_RemoveHiLoAndIndexCatalogName.Designer.cs ================================================ // using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Catalog.API.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { [DbContext(typeof(CatalogContext))] [Migration("20231018163051_RemoveHiLoAndIndexCatalogName")] partial class RemoveHiLoAndIndexCatalogName { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rc.2.23480.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogBrand", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Brand") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogBrand", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("AvailableStock") .HasColumnType("integer"); b.Property("CatalogBrandId") .HasColumnType("integer"); b.Property("CatalogTypeId") .HasColumnType("integer"); b.Property("Description") .HasColumnType("text"); b.Property("Embedding") .HasColumnType("vector(384)"); b.Property("MaxStockThreshold") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)"); b.Property("OnReorder") .HasColumnType("boolean"); b.Property("PictureFileName") .HasColumnType("text"); b.Property("Price") .HasColumnType("numeric"); b.Property("RestockThreshold") .HasColumnType("integer"); b.HasKey("Id"); b.HasIndex("CatalogBrandId"); b.HasIndex("CatalogTypeId"); b.HasIndex("Name"); b.ToTable("Catalog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogType", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Type") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogType", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.HasOne("eShop.Catalog.API.Model.CatalogBrand", "CatalogBrand") .WithMany() .HasForeignKey("CatalogBrandId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Catalog.API.Model.CatalogType", "CatalogType") .WithMany() .HasForeignKey("CatalogTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CatalogBrand"); b.Navigation("CatalogType"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231018163051_RemoveHiLoAndIndexCatalogName.cs ================================================ using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { /// public partial class RemoveHiLoAndIndexCatalogName : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.DropSequence( name: "catalog_brand_hilo"); migrationBuilder.DropSequence( name: "catalog_hilo"); migrationBuilder.DropSequence( name: "catalog_type_hilo"); migrationBuilder.AlterColumn( name: "Id", table: "CatalogType", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); migrationBuilder.AlterColumn( name: "Id", table: "CatalogBrand", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); migrationBuilder.AlterColumn( name: "Id", table: "Catalog", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); migrationBuilder.CreateIndex( name: "IX_Catalog_Name", table: "Catalog", column: "Name"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropIndex( name: "IX_Catalog_Name", table: "Catalog"); migrationBuilder.CreateSequence( name: "catalog_brand_hilo", incrementBy: 10); migrationBuilder.CreateSequence( name: "catalog_hilo", incrementBy: 10); migrationBuilder.CreateSequence( name: "catalog_type_hilo", incrementBy: 10); migrationBuilder.AlterColumn( name: "Id", table: "CatalogType", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); migrationBuilder.AlterColumn( name: "Id", table: "CatalogBrand", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); migrationBuilder.AlterColumn( name: "Id", table: "Catalog", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer") .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231026091140_Outbox.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Catalog.API.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { [DbContext(typeof(CatalogContext))] [Migration("20231026091140_Outbox")] partial class Outbox { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rtm.23512.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogBrand", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Brand") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogBrand", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("AvailableStock") .HasColumnType("integer"); b.Property("CatalogBrandId") .HasColumnType("integer"); b.Property("CatalogTypeId") .HasColumnType("integer"); b.Property("Description") .HasColumnType("text"); b.Property("Embedding") .HasColumnType("vector(384)"); b.Property("MaxStockThreshold") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)"); b.Property("OnReorder") .HasColumnType("boolean"); b.Property("PictureFileName") .HasColumnType("text"); b.Property("Price") .HasColumnType("numeric"); b.Property("RestockThreshold") .HasColumnType("integer"); b.HasKey("Id"); b.HasIndex("CatalogBrandId"); b.HasIndex("CatalogTypeId"); b.HasIndex("Name"); b.ToTable("Catalog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogType", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Type") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogType", (string)null); }); modelBuilder.Entity("eShop.IntegrationEventLogEF.IntegrationEventLogEntry", b => { b.Property("EventId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Content") .IsRequired() .HasColumnType("text"); b.Property("CreationTime") .HasColumnType("timestamp with time zone"); b.Property("EventTypeName") .IsRequired() .HasColumnType("text"); b.Property("State") .HasColumnType("integer"); b.Property("TimesSent") .HasColumnType("integer"); b.Property("TransactionId") .HasColumnType("uuid"); b.HasKey("EventId"); b.ToTable("IntegrationEventLog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.HasOne("eShop.Catalog.API.Model.CatalogBrand", "CatalogBrand") .WithMany() .HasForeignKey("CatalogBrandId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Catalog.API.Model.CatalogType", "CatalogType") .WithMany() .HasForeignKey("CatalogTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CatalogBrand"); b.Navigation("CatalogType"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/20231026091140_Outbox.cs ================================================ using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { /// public partial class Outbox : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "IntegrationEventLog", columns: table => new { EventId = table.Column(type: "uuid", nullable: false), EventTypeName = table.Column(type: "text", nullable: false), State = table.Column(type: "integer", nullable: false), TimesSent = table.Column(type: "integer", nullable: false), CreationTime = table.Column(type: "timestamp with time zone", nullable: false), Content = table.Column(type: "text", nullable: false), TransactionId = table.Column(type: "uuid", nullable: false) }, constraints: table => { table.PrimaryKey("PK_IntegrationEventLog", x => x.EventId); }); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "IntegrationEventLog"); } } } ================================================ FILE: src/Catalog.API/Infrastructure/Migrations/CatalogContextModelSnapshot.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Catalog.API.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Pgvector; #nullable disable namespace eShop.Catalog.API.Infrastructure.Migrations { [DbContext(typeof(CatalogContext))] partial class CatalogContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rtm.23512.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogBrand", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Brand") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogBrand", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("AvailableStock") .HasColumnType("integer"); b.Property("CatalogBrandId") .HasColumnType("integer"); b.Property("CatalogTypeId") .HasColumnType("integer"); b.Property("Description") .HasColumnType("text"); b.Property("Embedding") .HasColumnType("vector(384)"); b.Property("MaxStockThreshold") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)"); b.Property("OnReorder") .HasColumnType("boolean"); b.Property("PictureFileName") .HasColumnType("text"); b.Property("Price") .HasColumnType("numeric"); b.Property("RestockThreshold") .HasColumnType("integer"); b.HasKey("Id"); b.HasIndex("CatalogBrandId"); b.HasIndex("CatalogTypeId"); b.HasIndex("Name"); b.ToTable("Catalog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogType", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Type") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); b.HasKey("Id"); b.ToTable("CatalogType", (string)null); }); modelBuilder.Entity("eShop.IntegrationEventLogEF.IntegrationEventLogEntry", b => { b.Property("EventId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Content") .IsRequired() .HasColumnType("text"); b.Property("CreationTime") .HasColumnType("timestamp with time zone"); b.Property("EventTypeName") .IsRequired() .HasColumnType("text"); b.Property("State") .HasColumnType("integer"); b.Property("TimesSent") .HasColumnType("integer"); b.Property("TransactionId") .HasColumnType("uuid"); b.HasKey("EventId"); b.ToTable("IntegrationEventLog", (string)null); }); modelBuilder.Entity("eShop.Catalog.API.Model.CatalogItem", b => { b.HasOne("eShop.Catalog.API.Model.CatalogBrand", "CatalogBrand") .WithMany() .HasForeignKey("CatalogBrandId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Catalog.API.Model.CatalogType", "CatalogType") .WithMany() .HasForeignKey("CatalogTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CatalogBrand"); b.Navigation("CatalogType"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Catalog.API/IntegrationEvents/CatalogIntegrationEventService.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents; public sealed class CatalogIntegrationEventService(ILogger logger, IEventBus eventBus, CatalogContext catalogContext, IIntegrationEventLogService integrationEventLogService) : ICatalogIntegrationEventService, IDisposable { private volatile bool disposedValue; public async Task PublishThroughEventBusAsync(IntegrationEvent evt) { try { logger.LogInformation("Publishing integration event: {IntegrationEventId_published} - ({@IntegrationEvent})", evt.Id, evt); await integrationEventLogService.MarkEventAsInProgressAsync(evt.Id); await eventBus.PublishAsync(evt); await integrationEventLogService.MarkEventAsPublishedAsync(evt.Id); } catch (Exception ex) { logger.LogError(ex, "Error Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", evt.Id, evt); await integrationEventLogService.MarkEventAsFailedAsync(evt.Id); } } public async Task SaveEventAndCatalogContextChangesAsync(IntegrationEvent evt) { logger.LogInformation("CatalogIntegrationEventService - Saving changes and integrationEvent: {IntegrationEventId}", evt.Id); //Use of an EF Core resiliency strategy when using multiple DbContexts within an explicit BeginTransaction(): //See: https://docs.microsoft.com/en-us/ef/core/miscellaneous/connection-resiliency await ResilientTransaction.New(catalogContext).ExecuteAsync(async () => { // Achieving atomicity between original catalog database operation and the IntegrationEventLog thanks to a local transaction await catalogContext.SaveChangesAsync(); await integrationEventLogService.SaveEventAsync(evt, catalogContext.Database.CurrentTransaction); }); } private void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { (integrationEventLogService as IDisposable)?.Dispose(); } disposedValue = true; } } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } } ================================================ FILE: src/Catalog.API/IntegrationEvents/EventHandling/AnyFutureIntegrationEventHandler.cs.txt ================================================  // To implement ProductPriceChangedEvent.cs here ================================================ FILE: src/Catalog.API/IntegrationEvents/EventHandling/OrderStatusChangedToAwaitingValidationIntegrationEventHandler.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.EventHandling; public class OrderStatusChangedToAwaitingValidationIntegrationEventHandler( CatalogContext catalogContext, ICatalogIntegrationEventService catalogIntegrationEventService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToAwaitingValidationIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var confirmedOrderStockItems = new List(); foreach (var orderStockItem in @event.OrderStockItems) { var catalogItem = catalogContext.CatalogItems.Find(orderStockItem.ProductId); if (catalogItem is not null) { var hasStock = catalogItem.AvailableStock >= orderStockItem.Units; var confirmedOrderStockItem = new ConfirmedOrderStockItem(catalogItem.Id, hasStock); confirmedOrderStockItems.Add(confirmedOrderStockItem); } } var confirmedIntegrationEvent = confirmedOrderStockItems.Any(c => !c.HasStock) ? (IntegrationEvent)new OrderStockRejectedIntegrationEvent(@event.OrderId, confirmedOrderStockItems) : new OrderStockConfirmedIntegrationEvent(@event.OrderId); await catalogIntegrationEventService.SaveEventAndCatalogContextChangesAsync(confirmedIntegrationEvent); await catalogIntegrationEventService.PublishThroughEventBusAsync(confirmedIntegrationEvent); } } ================================================ FILE: src/Catalog.API/IntegrationEvents/EventHandling/OrderStatusChangedToPaidIntegrationEventHandler.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.EventHandling; public class OrderStatusChangedToPaidIntegrationEventHandler( CatalogContext catalogContext, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToPaidIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); //we're not blocking stock/inventory foreach (var orderStockItem in @event.OrderStockItems) { var catalogItem = catalogContext.CatalogItems.Find(orderStockItem.ProductId); catalogItem?.RemoveStock(orderStockItem.Units); } await catalogContext.SaveChangesAsync(); } } ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/ConfirmedOrderStockItem.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record ConfirmedOrderStockItem(int ProductId, bool HasStock); ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/OrderStatusChangedToAwaitingValidationIntegrationEvent.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record OrderStatusChangedToAwaitingValidationIntegrationEvent(int OrderId, IEnumerable OrderStockItems) : IntegrationEvent; ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/OrderStatusChangedToPaidIntegrationEvent.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record OrderStatusChangedToPaidIntegrationEvent(int OrderId, IEnumerable OrderStockItems) : IntegrationEvent; ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/OrderStockConfirmedIntegrationEvent.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record OrderStockConfirmedIntegrationEvent(int OrderId) : IntegrationEvent; ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/OrderStockItem.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record OrderStockItem(int ProductId, int Units); ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/OrderStockRejectedIntegrationEvent.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; public record OrderStockRejectedIntegrationEvent(int OrderId, List OrderStockItems) : IntegrationEvent; ================================================ FILE: src/Catalog.API/IntegrationEvents/Events/ProductPriceChangedIntegrationEvent.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents.Events; // Integration Events notes: // An Event is “something that has happened in the past”, therefore its name has to be past tense // An Integration Event is an event that can cause side effects to other microservices, Bounded-Contexts or external systems. public record ProductPriceChangedIntegrationEvent(int ProductId, decimal NewPrice, decimal OldPrice) : IntegrationEvent; ================================================ FILE: src/Catalog.API/IntegrationEvents/ICatalogIntegrationEventService.cs ================================================ namespace eShop.Catalog.API.IntegrationEvents; public interface ICatalogIntegrationEventService { Task SaveEventAndCatalogContextChangesAsync(IntegrationEvent evt); Task PublishThroughEventBusAsync(IntegrationEvent evt); } ================================================ FILE: src/Catalog.API/Model/CatalogBrand.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Catalog.API.Model; public class CatalogBrand { public CatalogBrand(string brand) { Brand = brand; } public int Id { get; set; } [Required] public string Brand { get; set; } } ================================================ FILE: src/Catalog.API/Model/CatalogItem.cs ================================================ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Pgvector; namespace eShop.Catalog.API.Model; public class CatalogItem { public int Id { get; set; } [Required] public string Name { get; set; } public string? Description { get; set; } public decimal Price { get; set; } public string? PictureFileName { get; set; } public int CatalogTypeId { get; set; } public CatalogType? CatalogType { get; set; } public int CatalogBrandId { get; set; } public CatalogBrand? CatalogBrand { get; set; } // Quantity in stock public int AvailableStock { get; set; } // Available stock at which we should reorder public int RestockThreshold { get; set; } // Maximum number of units that can be in-stock at any time (due to physicial/logistical constraints in warehouses) public int MaxStockThreshold { get; set; } /// Optional embedding for the catalog item's description. [JsonIgnore] public Vector? Embedding { get; set; } /// /// True if item is on reorder /// public bool OnReorder { get; set; } public CatalogItem(string name) { Name = name; } /// /// 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. /// /// If there is sufficient stock of an item, then the integer returned at the end of this call should be the same as quantityDesired. /// In the event that there is not sufficient stock available, the method will remove whatever stock is available and return that quantity to the client. /// In this case, it is the responsibility of the client to determine if the amount that is returned is the same as quantityDesired. /// It is invalid to pass in a negative number. /// /// /// int: Returns the number actually removed from stock. /// public int RemoveStock(int quantityDesired) { if (AvailableStock == 0) { throw new CatalogDomainException($"Empty stock, product item {Name} is sold out"); } if (quantityDesired <= 0) { throw new CatalogDomainException($"Item units desired should be greater than zero"); } int removed = Math.Min(quantityDesired, this.AvailableStock); this.AvailableStock -= removed; return removed; } /// /// Increments the quantity of a particular item in inventory. /// /// int: Returns the quantity that has been added to stock /// public int AddStock(int quantity) { int original = this.AvailableStock; // The quantity that the client is trying to add to stock is greater than what can be physically accommodated in the Warehouse if ((this.AvailableStock + quantity) > this.MaxStockThreshold) { // For now, this method only adds new units up maximum stock threshold. In an expanded version of this application, we //could include tracking for the remaining units and store information about overstock elsewhere. this.AvailableStock += (this.MaxStockThreshold - this.AvailableStock); } else { this.AvailableStock += quantity; } this.OnReorder = false; return this.AvailableStock - original; } } ================================================ FILE: src/Catalog.API/Model/CatalogServices.cs ================================================ using eShop.Catalog.API.Services; using Microsoft.AspNetCore.Mvc; public class CatalogServices( CatalogContext context, [FromServices] ICatalogAI catalogAI, IOptions options, ILogger logger, [FromServices] ICatalogIntegrationEventService eventService) { public CatalogContext Context { get; } = context; public ICatalogAI CatalogAI { get; } = catalogAI; public IOptions Options { get; } = options; public ILogger Logger { get; } = logger; public ICatalogIntegrationEventService EventService { get; } = eventService; }; ================================================ FILE: src/Catalog.API/Model/CatalogType.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Catalog.API.Model; public class CatalogType { public CatalogType(string type) { Type = type; } public int Id { get; set; } [Required] public string Type { get; set; } } ================================================ FILE: src/Catalog.API/Model/PaginatedItems.cs ================================================ using System.Text.Json.Serialization; namespace eShop.Catalog.API.Model; public class PaginatedItems(int pageIndex, int pageSize, long count, IEnumerable data) where TEntity : class { public int PageIndex { get; } = pageIndex; public int PageSize { get; } = pageSize; public long Count { get; } = count; public IEnumerable Data { get;} = data; } ================================================ FILE: src/Catalog.API/Model/PaginationRequest.cs ================================================ using System.ComponentModel; namespace eShop.Catalog.API.Model; public record PaginationRequest( [property: Description("Number of items to return in a single page of results")] [property: DefaultValue(10)] int PageSize = 10, [property: Description("The index of the page of results to return")] [property: DefaultValue(0)] int PageIndex = 0 ); ================================================ FILE: src/Catalog.API/Program.Testing.cs ================================================ // Require a public Program class to implement the // fixture for the WebApplicationFactory in the // integration tests. Using IVT is not sufficient // in this case, because the accessibility of the // `Program` type is checked. public partial class Program { } ================================================ FILE: src/Catalog.API/Program.cs ================================================ using Asp.Versioning.Builder; using System.Reflection; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddApplicationServices(); builder.Services.AddProblemDetails(); var withApiVersioning = builder.Services.AddApiVersioning(); builder.AddDefaultOpenApi(withApiVersioning); var app = builder.Build(); app.MapDefaultEndpoints(); app.UseStatusCodePages(); app.MapCatalogApi(); app.UseDefaultOpenApi(); app.Run(); ================================================ FILE: src/Catalog.API/Properties/launchSettings.json ================================================ { "profiles": { "http": { "commandName": "Project", "launchBrowser": true, "applicationUrl": "http://localhost:5222/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/Catalog.API/Services/CatalogAI.cs ================================================ using System.Diagnostics; using Microsoft.Extensions.AI; using Pgvector; namespace eShop.Catalog.API.Services; public sealed class CatalogAI : ICatalogAI { private const int EmbeddingDimensions = 384; private readonly IEmbeddingGenerator>? _embeddingGenerator; /// The web host environment. private readonly IWebHostEnvironment _environment; /// Logger for use in AI operations. private readonly ILogger _logger; public CatalogAI(IWebHostEnvironment environment, ILogger logger, IEmbeddingGenerator>? embeddingGenerator = null) { _embeddingGenerator = embeddingGenerator; _environment = environment; _logger = logger; } /// public bool IsEnabled => _embeddingGenerator is not null; /// public ValueTask GetEmbeddingAsync(CatalogItem item) => IsEnabled ? GetEmbeddingAsync(CatalogItemToString(item)) : ValueTask.FromResult(null); /// public async ValueTask?> GetEmbeddingsAsync(IEnumerable items) { if (IsEnabled) { long timestamp = Stopwatch.GetTimestamp(); GeneratedEmbeddings> embeddings = await _embeddingGenerator!.GenerateAsync(items.Select(CatalogItemToString)); var results = embeddings.Select(m => new Vector(m.Vector[0..EmbeddingDimensions])).ToList(); if (_logger.IsEnabled(LogLevel.Trace)) { _logger.LogTrace("Generated {EmbeddingsCount} embeddings in {ElapsedMilliseconds}s", results.Count, Stopwatch.GetElapsedTime(timestamp).TotalSeconds); } return results; } return null; } /// public async ValueTask GetEmbeddingAsync(string text) { if (IsEnabled) { long timestamp = Stopwatch.GetTimestamp(); var embedding = await _embeddingGenerator!.GenerateVectorAsync(text); embedding = embedding[0..EmbeddingDimensions]; if (_logger.IsEnabled(LogLevel.Trace)) { _logger.LogTrace("Generated embedding in {ElapsedMilliseconds}s: '{Text}'", Stopwatch.GetElapsedTime(timestamp).TotalSeconds, text); } return new Vector(embedding); } return null; } private static string CatalogItemToString(CatalogItem item) => $"{item.Name} {item.Description}"; } ================================================ FILE: src/Catalog.API/Services/ICatalogAI.cs ================================================ using Pgvector; namespace eShop.Catalog.API.Services; public interface ICatalogAI { /// Gets whether the AI system is enabled. bool IsEnabled { get; } /// Gets an embedding vector for the specified text. ValueTask GetEmbeddingAsync(string text); /// Gets an embedding vector for the specified catalog item. ValueTask GetEmbeddingAsync(CatalogItem item); /// Gets embedding vectors for the specified catalog items. ValueTask?> GetEmbeddingsAsync(IEnumerable item); } ================================================ FILE: src/Catalog.API/Setup/catalog.json ================================================ [ { "Id": 1, "Type": "Footwear", "Brand": "Daybird", "Name": "Wanderer Black Hiking Boots", "Description": "Daybird's Wanderer Hiking Boots in sleek black are perfect for all your outdoor adventures. These boots are made with a waterproof leather upper and a durable rubber sole for superior traction. With their cushioned insole and padded collar, these boots will keep you comfortable all day long.", "Price": 109.99 }, { "Id": 2, "Type": "Climbing", "Brand": "Gravitator", "Name": "Summit Pro Harness", "Description": "Conquer new heights with the Summit Pro Harness by Gravitator. This lightweight and durable climbing harness features adjustable leg loops and waist belt for a customized fit. With its vibrant blue color, you'll look stylish while maneuvering difficult routes. Safety is a top priority with a reinforced tie-in point and strong webbing loops.", "Price": 89.99 }, { "Id": 3, "Type": "Ski/boarding", "Brand": "WildRunner", "Name": "Alpine Fusion Goggles", "Description": "Enhance your skiing experience with the Alpine Fusion Goggles from WildRunner. These goggles offer full UV protection and anti-fog lenses to keep your vision clear on the slopes. With their stylish silver frame and orange lenses, you'll stand out from the crowd. Adjustable straps ensure a secure fit, while the soft foam padding provides comfort all day long.", "Price": 79.99 }, { "Id": 4, "Type": "Bags", "Brand": "Quester", "Name": "Expedition Backpack", "Description": "The Expedition Backpack by Quester is a must-have for every outdoor enthusiast. With its spacious interior and multiple pockets, you can easily carry all your gear and essentials. Made with durable nylon fabric, this backpack is built to withstand the toughest conditions. The orange accents add a touch of style to this functional backpack.", "Price": 129.99 }, { "Id": 5, "Type": "Ski/boarding", "Brand": "B&R", "Name": "Blizzard Rider Snowboard", "Description": "Get ready to ride the slopes with the Blizzard Rider Snowboard by B&R. This versatile snowboard is perfect for riders of all levels with its medium flex and twin shape. Its black and blue color scheme gives it a sleek and cool look. Whether you're carving turns or hitting the terrain park, this snowboard will help you shred with confidence.", "Price": 299.99 }, { "Id": 6, "Type": "Trekking", "Brand": "Raptor Elite", "Name": "Carbon Fiber Trekking Poles", "Description": "The Carbon Fiber Trekking Poles by Raptor Elite are the ultimate companion for your hiking adventures. Designed with lightweight carbon fiber shafts, these poles provide excellent support and durability. The comfortable and adjustable cork grips ensure a secure hold, while the blue accents add a stylish touch. Compact and collapsible, these trekking poles are easy to transport and store.", "Price": 69.99 }, { "Id": 7, "Type": "Bags", "Brand": "Solstix", "Name": "Explorer 45L Backpack", "Description": "The Explorer 45L Backpack by Solstix is perfect for your next outdoor expedition. Made with waterproof and tear-resistant materials, this backpack can withstand even the harshest weather conditions. With its spacious main compartment and multiple pockets, you can easily organize your gear. The green and black color scheme adds a rugged and adventurous edge.", "Price": 149.99 }, { "Id": 8, "Type": "Jackets", "Brand": "Grolltex", "Name": "Frostbite Insulated Jacket", "Description": "Stay warm and stylish with the Frostbite Insulated Jacket by Grolltex. Featuring a water-resistant outer shell and lightweight insulation, this jacket is perfect for cold weather adventures. The black and gray color combination and Grolltex logo add a touch of sophistication. With its adjustable hood and multiple pockets, this jacket offers both style and functionality.", "Price": 179.99 }, { "Id": 9, "Type": "Navigation", "Brand": "AirStrider", "Name": "VenturePro GPS Watch", "Description": "Navigate with confidence using the VenturePro GPS Watch by AirStrider. This rugged and durable watch features a built-in GPS, altimeter, and compass, allowing you to track your progress and find your way in any terrain. With its sleek black design and easy-to-read display, this watch is both stylish and practical. The VenturePro GPS Watch is a must-have for every adventurer.", "Price": 199.99 }, { "Id": 10, "Type": "Cycling", "Brand": "Green Equipment", "Name": "Trailblazer Bike Helmet", "Description": "Stay safe on your cycling adventures with the Trailblazer Bike Helmet by Green Equipment. This lightweight and durable helmet features an adjustable fit system and ventilation for added comfort. With its vibrant green color and sleek design, you'll stand out on the road. The Trailblazer Bike Helmet is perfect for all types of cycling, from mountain biking to road cycling.", "Price": 59.99 }, { "Id": 11, "Type": "Climbing", "Brand": "WildRunner", "Name": "Vertical Journey Climbing Shoes", "Description": "The Vertical Journey Climbing Shoes from WildRunner in sleek black are the perfect companion for any climbing enthusiast. With an aggressive down-turned toe, sticky rubber outsole, and reinforced heel cup for added support, these shoes offer ultimate performance on even the most challenging routes.", "Price": 129.99 }, { "Id": 12, "Type": "Ski/boarding", "Brand": "Zephyr", "Name": "Powder Pro Snowboard", "Description": "The Powder Pro Snowboard by Zephyr is designed for the ultimate ride through deep snow. Its floating camber allows for effortless turns and smooth maneuverability, while the lightweight carbon fiber construction ensures maximum control at high speeds. This board, available in vibrant turquoise, is a must-have for any backcountry shredder.", "Price": 399.00 }, { "Id": 13, "Type": "Bags", "Brand": "Daybird", "Name": "Trailblaze hiking backpack", "Description": "The Daybird Trailblaze backpack in forest green is a reliable and spacious bag for all your outdoor adventures. With a 40-liter capacity and durable ripstop fabric, this backpack provides ample storage and protection for your gear. Its ergonomic design and adjustable straps ensure a comfortable fit no matter the length of the hike.", "Price": 89.99 }, { "Id": 14, "Type": "Bags", "Brand": "Gravitator", "Name": "Stellar Duffle Bag", "Description": "The Stellar Duffle Bag from Gravitator is perfect for weekend getaways or short trips. Made from waterproof nylon and available in sleek black, it features multiple internal pockets and a separate shoe compartment to keep your belongings organized. With its adjustable shoulder strap and reinforced handles, this bag is as functional as it is stylish.", "Price": 59.99 }, { "Id": 15, "Type": "Jackets", "Brand": "Raptor Elite", "Name": "Summit Pro Insulated Jacket", "Description": "The Summit Pro Insulated Jacket by Raptor Elite is designed to keep you warm and dry in extreme conditions. With its waterproof and breathable construction, heat-sealed seams, and insulation made from recycled materials, this jacket is both eco-friendly and high-performance. Available in vibrant red, it also features a removable hood and plenty of storage pockets.", "Price": 249.99 }, { "Id": 16, "Type": "Ski/boarding", "Brand": "Solstix", "Name": "Expedition 2022 Goggles", "Description": "Solstix Expedition 2022 Goggles provide clear vision and optimal protection on the slopes. With an anti-fog lens, UV protection, and a comfortable foam lining, these goggles ensure a great fit and unrestricted vision even in challenging conditions. The matte black frame gives them a sleek and modern look.", "Price": 89.00 }, { "Id": 17, "Type": "Climbing", "Brand": "Legend", "Name": "Apex Climbing Harness", "Description": "The Apex Climbing Harness by Legend is a lightweight and durable harness designed for maximum comfort and safety. With adjustable leg loops, a contoured waistbelt, and a secure buckle system, this harness provides a secure fit for all-day climbing sessions. Available in bold orange, it also features gear loops for easy access to your equipment.", "Price": 89.99 }, { "Id": 18, "Type": "Climbing", "Brand": "Grolltex", "Name": "Alpine Tech Crampons", "Description": "The Alpine Tech Crampons by Grolltex are essential for icy and challenging mountain terrains. Made from strong and lightweight stainless steel, these crampons provide excellent traction and stability. Their simple adjustment system allows for easy fitting and quick attachment to most hiking boots. Available in silver, they are suitable for both beginners and experienced mountaineers.", "Price": 149.00 }, { "Id": 19, "Type": "Footwear", "Brand": "Green Equipment", "Name": "EcoTrail Running Shoes", "Description": "Experience the great outdoors while reducing your carbon footprint with the Green Equipment EcoTrail Running Shoes. Made from recycled materials, these shoes offer a lightweight, breathable, and flexible design in an earthy green color. With their durable Vibram outsole and cushioned midsole, they provide optimal comfort and grip on any trail.", "Price": 119.99 }, { "Id": 20, "Type": "Navigation", "Brand": "B&R", "Name": "Explorer Biking Computer", "Description": "The Explorer Biking Computer by B&R is the ultimate accessory for cyclists seeking data and navigation assistance. With its intuitive touchscreen display and GPS capabilities, it allows you to track your route, monitor performance metrics, and receive turn-by-turn directions. Its sleek black design and waterproof construction make it a reliable companion on all your cycling adventures.", "Price": 199.99 }, { "Id": 21, "Type": "Footwear", "Brand": "Legend", "Name": "Trailblazer Black Hiking Shoes", "Description": "The Legend Trailblazer is a versatile hiking shoe designed to provide unparalleled durability and comfort on any adventure. With its black color, these shoes offer a sleek and minimalist style. The shoes feature a waterproof GORE-TEX lining, Vibram rubber outsole for enhanced traction, and a reinforced toe cap for added protection. Conquer any trail with confidence in the Legend Trailblazer Black Hiking Shoes.", "Price": 129.99 }, { "Id": 22, "Type": "Ski/boarding", "Brand": "Raptor Elite", "Name": "Venture 2022 Snowboard", "Description": "The Raptor Elite Venture 2022 Snowboard is a true all-mountain performer, perfect for riders of all levels. Its sleek design, combined with the vibrant blue color, makes it stand out on the slopes. The snowboard features a responsive camber profile, carbon fiber laminates for enhanced stability, and a sintered base for maximum speed. Take your snowboarding skills to new heights with the Raptor Elite Venture 2022 Snowboard.", "Price": 499.00 }, { "Id": 23, "Type": "Climbing", "Brand": "Zephyr", "Name": "Summit Pro Climbing Harness", "Description": "The Zephyr Summit Pro Climbing Harness is designed for professional climbers who demand the utmost in reliability and performance. Available in a striking orange color, this harness features 30kN rated webbing, speed-adjust buckles, and multiple gear loops for easy organization. With its lightweight design, the Summit Pro Harness offers unmatched comfort and freedom of movement. Reach new heights of confidence with the Zephyr Summit Pro Climbing Harness.", "Price": 189.99 }, { "Id": 24, "Type": "Bags", "Brand": "WildRunner", "Name": "Ridgevent Stealth Hiking Backpack", "Description": "The WildRunner Ridgevent Stealth Hiking Backpack is the ultimate companion for your outdoor adventures. With its stealthy red color, this backpack combines style with functionality. Made from durable nylon and featuring multiple compartments, this backpack offers ample storage space for all your essentials. Whether you're venturing into the mountains or exploring hidden trails, the Ridgevent Stealth Hiking Backpack has got you covered.", "Price": 69.99 }, { "Id": 25, "Type": "Cycling", "Brand": "Daybird", "Name": "Stealth Lite Bike Helmet", "Description": "The Daybird Stealth Lite Bike Helmet is designed for cyclists who value both safety and style. With its sleek matte silver color, this helmet will make you stand out on the road. The helmet features a lightweight in-mold construction, adjustable retention system, and multiple ventilation channels for optimal airflow. Stay protected and look cool with the Daybird Stealth Lite Bike Helmet.", "Price": 89.99 }, { "Id": 26, "Type": "Climbing", "Brand": "Gravitator", "Name": "Gravity Beam Climbing Rope", "Description": "The Gravitator Gravity Beam Climbing Rope is the perfect companion for vertical endeavors. This high-quality climbing rope features a kernmantle construction, providing excellent strength and durability. With its vibrant yellow color, the Gravity Beam Rope is highly visible and easy to work with. Whether you're tackling steep rock faces or conquering frozen waterfalls, trust the Gravitator Gravity Beam Climbing Rope to get you to the top.", "Price": 179.99 }, { "Id": 27, "Type": "Bags", "Brand": "Green Equipment", "Name": "EcoLodge 45L Travel Backpack", "Description": "The Green Equipment EcoLodge 45L Travel Backpack is a sustainable and versatile option for all your travel needs. With its earth-inspired green color, this backpack is not only stylish but also environmentally friendly. Made from recycled materials, this backpack features multiple compartments, a padded laptop sleeve, and durable zippers. Explore the world with the Green Equipment EcoLodge 45L Travel Backpack.", "Price": 129.00 }, { "Id": 28, "Type": "Jackets", "Brand": "Solstix", "Name": "Alpine Peak Down Jacket", "Description": "The Solstix Alpine Peak Down Jacket is crafted for extreme cold conditions. With its bold red color and sleek design, this jacket combines style with functionality. Made with high-quality goose down insulation, the Alpine Peak Jacket provides exceptional warmth and comfort. The jacket features a removable hood, adjustable cuffs, and multiple zippered pockets for storage. Conquer the harshest weather with the Solstix Alpine Peak Down Jacket.", "Price": 249.99 }, { "Id": 29, "Type": "Navigation", "Brand": "B&R", "Name": "Pulse Recon Tactical GPS Watch", "Description": "The B&R Pulse Recon Tactical GPS Watch is a must-have for outdoor enthusiasts. This reliable navigation tool features a built-in GPS, altimeter, compass, and multiple sports modes. With its military green color and durable construction, the Pulse Recon watch is built to withstand your toughest adventures. Stay on track and keep track of your performance with the B&R Pulse Recon Tactical GPS Watch.", "Price": 169.00 }, { "Id": 30, "Type": "Ski/boarding", "Brand": "Gravitator", "Name": "Zero Gravity Ski Goggles", "Description": "The Gravitator Zero Gravity Ski Goggles combine style, performance, and comfort for the ultimate ski experience. With their sleek white frame and red mirrored lenses, these goggles offer a futuristic look on the slopes. The goggles feature an anti-fog coating, 100% UV protection, and an adjustable strap for a secure fit. Enhance your vision and carve your way down the slopes with the Gravitator Zero Gravity Ski Goggles.", "Price": 79.99 }, { "Id": 31, "Type": "Climbing", "Brand": "Legend", "Name": "Guardian Blue Chalk Bag", "Description": "Stay focused on your route with the Guardian Blue Chalk Bag by Legend. This durable bag features a spacious compartment for your chalk, a drawstring closure, and a waist belt for easy access while climbing. The vibrant blue color adds a stylish touch to your climbing gear.", "Price": 21.99 }, { "Id": 32, "Type": "Ski/boarding", "Brand": "Gravitator", "Name": "Cosmic Purple Snowboard", "Description": "Conquer the slopes with the Cosmic Purple Snowboard by Gravitator. This freestyle board delivers a perfect balance of control and maneuverability. Its bright purple design is complemented by the Gravitator emblem, sure to turn heads on the mountain.", "Price": 419.99 }, { "Id": 33, "Type": "Footwear", "Brand": "WildRunner", "Name": "Venture Grey Trail Shoes", "Description": "Hit the trails in style and comfort with the Venture Grey Trail Shoes by WildRunner. Constructed with breathable mesh and a rugged outsole, these shoes provide excellent traction and long-lasting durability. The versatile grey color makes them suitable for any adventure.", "Price": 79.99 }, { "Id": 34, "Type": "Cycling", "Brand": "AirStrider", "Name": "Velocity Red Bike Helmet", "Description": "Protect yourself while cycling in style with the Velocity Red Bike Helmet by AirStrider. This lightweight helmet features a streamlined design, adjustable straps, and ventilation channels for optimal airflow. Stay safe on the road or trails with this vibrant red helmet.", "Price": 54.99 }, { "Id": 35, "Type": "Trekking", "Brand": "Raptor Elite", "Name": "Carbon Fiber Trekking Poles", "Description": "Hike with confidence using the Raptor Elite Carbon Fiber Trekking Poles. These lightweight and durable poles provide stability on various terrains and reduce strain on your joints. With an ergonomic grip and adjustable length, these poles are a must-have for your outdoor adventures.", "Price": 99.00 }, { "Id": 36, "Type": "Bags", "Brand": "B&R", "Name": "Excursion 20L Daypack", "Description": "The Excursion 20L Daypack by B&R is the perfect companion for your hiking or camping trips. Made from durable waterproof nylon, this spacious pack features multiple pockets, adjustable straps, and a padded back for enhanced comfort. The sleek design and versatile white color make it a stylish choice.", "Price": 64.99 }, { "Id": 37, "Type": "Jackets", "Brand": "Zephyr", "Name": "Stormbreaker Waterproof Jacket", "Description": "Take on any weather with the Stormbreaker Waterproof Jacket by Zephyr. This jacket offers superior protection with its fully waterproof and windproof design. The bold red color, coupled with the Zephyr logo, adds a stylish touch to your outdoor look. Stay dry and comfortable during your adventures.", "Price": 139.99 }, { "Id": 38, "Type": "Navigation", "Brand": "Solstix", "Name": "Pathfinder Portable GPS", "Description": "Never lose your way with the Pathfinder Portable GPS by Solstix. This compact and reliable navigation device features a color display, preloaded maps, and advanced tracking capabilities. With its intuitive interface and long battery life, you can explore confidently wherever you go.", "Price": 199.00 }, { "Id": 39, "Type": "Ski/boarding", "Brand": "Daybird", "Name": "Midnight Blue Goggles", "Description": "Enhance your snowboarding experience with the Midnight Blue Goggles by Daybird. These goggles offer a wide field of vision, anti-fog coating, and UV protection to keep your eyes protected on the slopes. The sleek design and blue tinted lens add a touch of style to your riding gear.", "Price": 89.99 }, { "Id": 40, "Type": "Footwear", "Brand": "Green Equipment", "Name": "EcoTrek Trail Running Shoes", "Description": "Hit the trails with the EcoTrek Trail Running Shoes by Green Equipment. Designed with eco-friendly materials, these shoes feature a comfortable fit, responsive cushioning, and a durable outsole for optimal grip on rugged terrains. The forest green color is inspired by nature and adds a refreshing touch to your outdoor look.", "Price": 99.99 }, { "Id": 41, "Type": "Footwear", "Brand": "WildRunner", "Name": "Trekker Clear Hiking Shoes", "Description": "The Trekker Clear Hiking Shoes from WildRunner are designed for the adventurous hiker who seeks both comfort and durability. The transparent shoes feature a waterproof and breathable upper fabric, a rugged carbon-infused sole for excellent traction, and a shock-absorbing midsole for enhanced comfort on long hikes.", "Price": 84.99 }, { "Id": 42, "Type": "Ski/boarding", "Brand": "Gravitator", "Name": "Gravity 5000 All-Mountain Skis", "Description": "Take on any slope confidently with the Gravity 5000 All-Mountain Skis by Gravitator. These skis feature a versatile design that excels in all conditions, from powder to hardpack. They are equipped with a lightweight wood core, carbon inserts for responsiveness, and ABS sidewalls for added durability. These skis come in a striking blue color.", "Price": 699.00 }, { "Id": 43, "Type": "Ski/boarding", "Brand": "Legend", "Name": "Glacier Frost Snowboard", "Description": "Tame the snow-covered peaks with the Glacier Frost Snowboard from Legend. This high-performance board is constructed with a hybrid camber profile for excellent edge control and superior maneuverability. The board is built with a carbon fiber composite core for lightweight strength and optimal flex, enabling you to take your snowboarding skills to new heights. Available in a cool white color with vibrant frost graphic.", "Price": 419.99 }, { "Id": 44, "Type": "Climbing", "Brand": "Raptor Elite", "Name": "Summit Pro Climbing Harness", "Description": "Conquer the highest peaks with the Summit Pro Climbing Harness by Raptor Elite. This harness features a lightweight and breathable construction, complete with adjustable leg loops for a personalized fit. It has reinforced tie-in points for maximum safety and durability. The vivid green color adds a touch of style to your climbing gear.", "Price": 109.99 }, { "Id": 45, "Type": "Trekking", "Brand": "Solstix", "Name": "Elemental 3-Season Tent", "Description": "Experience the great outdoors with the Elemental 3-Season Tent by Solstix. This lightweight and compact tent is perfect for backpacking adventures. It offers ample space for two people and features a durable waterproof fabric, sturdy aluminum poles, and ventilation panels for optimal airflow. The vibrant green color adds a touch of visibility to your camping setup.", "Price": 189.99 }, { "Id": 46, "Type": "Cycling", "Brand": "B&R", "Name": "Zenith Cycling Jersey", "Description": "Get ready to hit the road with the Zenith Cycling Jersey by B&R. This high-performance jersey is made from moisture-wicking fabric to keep you cool and dry during intense rides. It features a full-length zipper, three rear pockets for storage, and reflective accents for increased visibility in low-light conditions. Available in a vibrant red color.", "Price": 64.99 }, { "Id": 47, "Type": "Climbing", "Brand": "Grolltex", "Name": "Edge Pro Ice Axe", "Description": "Take your ice climbing adventures to the next level with the Edge Pro Ice Axe from Grolltex. This axe features a lightweight aluminum shaft, a durable stainless steel pick, and a comfortable hand grip for maximum control. Perfect for tackling steep ice walls and mixed alpine terrain. The sleek orange color adds a touch of sophistication to your gear.", "Price": 129.00 }, { "Id": 48, "Type": "Bags", "Brand": "Zephyr", "Name": "Trailblazer 45L Backpack", "Description": "Take everything you need for your next adventure with the Trailblazer 45L Backpack by Zephyr. This spacious backpack features multiple compartments for easy organization, adjustable shoulder straps and hip belt for a customized fit, and durable waterproof construction for ultimate protection against the elements. The classic yellow color is timeless and versatile.", "Price": 124.99 }, { "Id": 49, "Type": "Jackets", "Brand": "Daybird", "Name": "Arctic Shield Insulated Jacket", "Description": "Stay warm and stylish in the Arctic Shield Insulated Jacket by Daybird. This jacket features a water-resistant outer shell, insulated fill for exceptional warmth, and a detachable hood for added versatility. The sleek pink color is perfect for any outdoor occasion.", "Price": 169.99 }, { "Id": 50, "Type": "Navigation", "Brand": "AirStrider", "Name": "Astro GPS Navigator", "Description": "Never get lost on your outdoor adventures with the Astro GPS Navigator by AirStrider. This compact and rugged device comes loaded with topographic maps, GPS tracking, waypoint storage, and a long-lasting battery. It is equipped with a high-resolution color display for easy navigation in any lighting condition. Available in a sleek gray color.", "Price": 249.99 }, { "Id": 51, "Type": "Climbing", "Brand": "Grolltex", "Name": "SummitStone Chalk Bag", "Description": "The SummitStone Chalk Bag in forest green is a must-have for climbers seeking adventure. Keep your hands dry and have easy access to chalk with this durable and compact bag. It features a drawstring closure, adjustable waist strap, and a Loop-Slider buckle for easy attachment to harnesses.", "Price": 29.99 }, { "Id": 52, "Type": "Bags", "Brand": "Legend", "Name": "TrailHug 50L Backpack", "Description": "The TrailHug 50L Backpack in navy blue is a perfect companion for all your hiking adventures. Made from lightweight and water-resistant nylon, this backpack features adjustable padded straps, multiple compartments for organized storage, and a breathable back panel for added comfort. It also comes with a handy built-in rain cover for unexpected showers.", "Price": 129.99 }, { "Id": 53, "Type": "Ski/boarding", "Brand": "Daybird", "Name": "Raven Swift Snowboard", "Description": "The Raven Swift Snowboard is ready to take you on thrilling rides down the slopes. With its striking white design and black logo, this all-mountain board is perfect for riders of all skill levels. It features a camber profile for stability and pop, and a medium flex for smooth turns and responsive control. Get ready to fly and carve like never before!", "Price": 349.00 }, { "Id": 54, "Type": "Trekking", "Brand": "Gravitator", "Name": "Nebula Pro Headlamp", "Description": "Illuminate your outdoor adventures with the Nebula Pro Headlamp. Its sleek design and water-resistant construction in neon color make it perfect for night hikes, camping trips, and emergencies. With 500 lumens of bright white light, adjustable brightness modes, and a rechargeable battery, this headlamp will light up the darkness and keep you safe.", "Price": 59.99 }, { "Id": 55, "Type": "Jackets", "Brand": "Solstix", "Name": "Vigor 2.0 Insulated Jacket", "Description": "Stay warm and stylish on the slopes with the Vigor 2.0 Insulated Jacket in vibrant red. This waterproof and breathable jacket is made with a 2-layer technical shell and features a detachable hood, adjustable cuffs, and multiple pockets for storage. With its modern design and ergonomic fit, it's the perfect outer layer for your winter adventures.", "Price": 189.99 }, { "Id": 56, "Type": "Bags", "Brand": "B&R", "Name": "Traveler's Companion Duffel Bag", "Description": "Whether you're embarking on a weekend getaway or a month-long expedition, the Traveler's Companion Duffel Bag has you covered. Made from durable waxed canvas in earthy brown, this versatile bag features multiple carry options, including shoulder straps and handles, and multiple compartments for organized packing. It even has a padded laptop sleeve, making it great for both adventure and work.", "Price": 79.99 }, { "Id": 57, "Type": "Footwear", "Brand": "Zephyr", "Name": "Ascend XT Trail Running Shoes", "Description": "Take on any trail with confidence in the Ascend XT Trail Running Shoes in charcoal gray. These lightweight yet rugged shoes offer excellent grip and support, thanks to their durable rubber outsole and advanced cushioning technology. The breathable mesh upper keeps your feet cool when the adventure heats up. It's time to push your limits and conquer the great outdoors.", "Price": 109.99 }, { "Id": 58, "Type": "Cycling", "Brand": "Raptor Elite", "Name": "VelociX 2000 Bike Helmet", "Description": "Protect your head in style with the VelociX 2000 Bike Helmet in glossy black. This aerodynamic helmet features an adjustable fit system, detachable visor, and 14 ventilation channels to keep you cool during intense rides. With its sleek design and lightweight construction, it's the perfect choice for road cycling, mountain biking, and everything in between.", "Price": 79.99 }, { "Id": 59, "Type": "Navigation", "Brand": "Quester", "Name": "TrailSeeker GPS Watch", "Description": "Stay on track and explore new trails with the TrailSeeker GPS Watch. With its durable design in stealth black, this watch is packed with features like GPS navigation, heart rate monitoring, and activity tracking. It also offers a long battery life, so you can keep going without worrying about recharging. The TrailSeeker is the ultimate companion for outdoor enthusiasts who love to explore.", "Price": 149.99 }, { "Id": 60, "Type": "Ski/boarding", "Brand": "WildRunner", "Name": "SummitRider Snowboard Boots", "Description": "Conquer the mountains in style with the SummitRider Snowboard Boots in matte black. These high-performance boots combine comfort, durability, and response to enhance your riding experience. Featuring a heat-moldable liner, dual-zone lacing system, and impact-resistant outsole, they provide a precise and snug fit, ensuring maximum control on any terrain. Get ready to take on the slopes like a pro!", "Price": 249.00 }, { "Id": 61, "Type": "Footwear", "Brand": "WildRunner", "Name": "Trailblaze Steel-Blue Hiking Shoes", "Description": "Explore the great outdoors with the Trailblaze Steel-Blue Hiking Shoes by WildRunner. These rugged and durable shoes feature a steel-blue color, a waterproof membrane, and a high-traction rubber outsole for superior grip on any terrain. The breathable upper keeps your feet cool and comfortable, while the reinforced toe cap adds extra protection. Perfect for hiking, camping, and other outdoor adventures.", "Price": 129.99 }, { "Id": 62, "Type": "Ski/boarding", "Brand": "Daybird", "Name": "Shadow Black Snowboard", "Description": "Conquer the slopes with the Daybird Shadow Black Snowboard. This sleek and stylish snowboard features a black colorway, a camber profile for maximum stability, and a medium-flex rating for responsive turns and tricks. Its lightweight construction ensures easy maneuverability, while the sintered base provides excellent speed and durability. Whether you're shredding on groomed runs or exploring the backcountry, this snowboard is designed to deliver peak performance.", "Price": 379.00 }, { "Id": 63, "Type": "Climbing", "Brand": "Raptor Elite", "Name": "Razor Climbing Harness", "Description": "Reach new heights with the Raptor Elite Razor Climbing Harness. This lightweight and breathable harness is designed for maximum comfort and performance. With its adjustable waist and leg loops, it offers a secure and customized fit. The razor-shaped webbing adds a stylish touch to the blue color of the harness. Featuring durable construction and reinforced tie-in points, this harness is a must-have for climbers of all levels.", "Price": 94.99 }, { "Id": 64, "Type": "Bags", "Brand": "Green Equipment", "Name": "EcoVenture Olive Green Backpack", "Description": "Embark on your next adventure with the Green Equipment EcoVenture Olive Green Backpack. Made from recycled materials, this sustainable backpack is as eco-friendly as it is functional. It features a spacious main compartment, multiple pockets for organizing your gear, and adjustable padded shoulder straps for comfortable carrying. The olive green color adds a touch of nature to your outdoor excursions.", "Price": 69.99 }, { "Id": 65, "Type": "Cycling", "Brand": "Solstix", "Name": "Sprint PRO Carbon Cycling Helmet", "Description": "Stay safe while cycling with the Solstix Sprint PRO Carbon Cycling Helmet. This high-performance helmet is crafted from carbon fiber for optimal impact protection and durability. It features an aerodynamic design, adjustable fit system, and ventilation channels to keep you cool on long rides. The rainbow color with the Solstix emblem adds a touch of style to your cycling adventures.", "Price": 179.99 }, { "Id": 66, "Type": "Navigation", "Brand": "B&R", "Name": "Compass Pro A-320 Professional Compass", "Description": "Navigate with precision using the B&R Compass Pro A-320 Professional Compass. Designed for outdoor enthusiasts and professionals alike, this compass features a liquid-filled housing for accurate readings, a rotating bezel for easy navigation, and a lanyard for convenient carrying. The gunmetal color with white markings ensures clarity and visibility even in low-light conditions. Get ready to explore the wilderness with confidence.", "Price": 59.99 }, { "Id": 67, "Type": "Bags", "Brand": "Quester", "Name": "Venture 2.0 40L Waterproof Duffel Bag", "Description": "Pack your gear in the Quester Venture 2.0 40L Waterproof Duffel Bag. This versatile duffel bag is made from waterproof nylon material and features taped seams to keep your belongings safe from the elements. It offers a spacious main compartment, external zippered pockets, and adjustable shoulder straps for easy carrying. The vibrant orange color adds a pop of excitement to your outdoor adventures.", "Price": 79.99 }, { "Id": 68, "Type": "Jackets", "Brand": "Grolltex", "Name": "Mens Horizon 80s Softshell Jacket", "Description": "Stay protected from the elements in the Grolltex Mens Horizon 80s Softshell Jacket. Made from a water-resistant and breathable fabric in retro 1980s style, this jacket keeps you dry and comfortable in any weather. It features multiple colors, a detachable hood, adjustable cuffs, and multiple pockets for storing your essentials. Whether you're hiking, skiing, or exploring the city, this jacket combines style and functionality.", "Price": 169.99 }, { "Id": 69, "Type": "Navigation", "Brand": "Gravitator", "Name": "Expedition 200 GPS Navigator", "Description": "Navigate with confidence using the Gravitator Expedition 200 GPS Navigator. This rugged and reliable navigator features a built-in GPS antenna for accurate positioning, preloaded maps, and a user-friendly interface with intuitive controls. The black color with the Gravitator logo complements the sleek design. With its long battery life and durable construction, this navigator is your ultimate outdoor companion.", "Price": 299.00 }, { "Id": 70, "Type": "Trekking", "Brand": "WildRunner", "Name": "GripTrek Hiking Poles", "Description": "The GripTrek hiking poles by WildRunner are a must-have for adventurers. With their durable aluminum construction and anti-slip handles, these poles provide stability and support on any terrain. Available in sleek yellow, these hiking poles are perfect for tackling steep inclines and rough trails.", "Price": 79.99 }, { "Id": 71, "Type": "Footwear", "Brand": "Daybird", "Name": "Explorer Frost Boots", "Description": "The Explorer Frost Boots by Daybird are the perfect companion for cold-weather adventures. These premium boots are designed with a waterproof and insulated shell, keeping your feet warm and protected in icy conditions. The sleek black design with blue accents adds a touch of style to your outdoor gear.", "Price": 149.99 }, { "Id": 72, "Type": "Ski/boarding", "Brand": "Gravitator", "Name": "GravityZone All-Mountain Skis", "Description": "Take your skiing to new heights with the GravityZone All-Mountain Skis by Gravitator. These high-performance skis are designed for precision and control on all types of terrain. The sleek metallic blue design will make you stand out on the slopes while the carbon fiber construction ensures lightweight durability.", "Price": 699.00 }, { "Id": 73, "Type": "Ski/boarding", "Brand": "WildRunner", "Name": "Omni-Snow Dual Snowboard", "Description": "Unleash your snowboarding skills with the Omni-Snow Dual Snowboard by WildRunner. This innovative design combines the maneuverability of a skateboard with the speed and stability of a snowboard. The vibrant red and black color scheme adds a dash of excitement to your snowboarding adventures.", "Price": 289.99 }, { "Id": 74, "Type": "Climbing", "Brand": "Raptor Elite", "Name": "Apex Climbing Harness", "Description": "Conquer the heights with the Apex Climbing Harness by Raptor Elite. This harness is constructed with high-strength nylon webbing and features adjustable leg loops for a secure and comfortable fit. The sleek white design with a vibrant orange emblem ensures you'll look good while tackling challenging routes.", "Price": 99.99 }, { "Id": 75, "Type": "Footwear", "Brand": "AirStrider", "Name": "TrailTracker Hiking Shoes", "Description": "The TrailTracker Hiking Shoes by AirStrider are built to handle any terrain. These lightweight and breathable shoes feature a rugged rubber sole for excellent traction and stability. The cool gray color with green accents adds a touch of style to your hiking ensemble.", "Price": 89.99 }, { "Id": 76, "Type": "Cycling", "Brand": "B&R", "Name": "Fusion Carbon Cycling Helmet", "Description": "Protect yourself on two wheels with the Fusion Carbon Cycling Helmet by B&R. This helmet is made from lightweight carbon fiber and features an aerodynamic design for maximum speed. The colorful finish with a bold blue stripe will make you stand out on the road.", "Price": 159.00 }, { "Id": 77, "Type": "Trekking", "Brand": "XE", "Name": "Survivor 2-Person Tent", "Description": "Gear up for your next adventure with the Survivor 2-Person Tent by XE. This rugged tent is made from durable ripstop nylon and features a waterproof coating to keep you dry in any weather. The vibrant orange color ensures high visibility in the wild.", "Price": 249.99 }, { "Id": 78, "Type": "Bags", "Brand": "Solstix", "Name": "Basecamp Duffle Bag", "Description": "The Basecamp Duffle Bag by Solstix is the ultimate adventure companion. This spacious bag is made from durable nylon and features multiple compartments for optimal organization. Its sleek red design with gray accents exudes both style and functionality.", "Price": 129.00 }, { "Id": 79, "Type": "Jackets", "Brand": "Legend", "Name": "Everest Insulated Jacket", "Description": "Conquer the cold with the Everest Insulated Jacket by Legend. This jacket combines warmth and style with its insulated design and sleek grey color. The water-resistant shell will keep you dry during unexpected showers while the cozy fleece lining adds extra comfort.", "Price": 179.99 }, { "Id": 80, "Type": "Navigation", "Brand": "XE", "Name": "Pathfinder GPS Watch", "Description": "Navigate with confidence using the Pathfinder GPS Watch by XE. This feature-packed watch includes GPS tracking, altimeter, barometer, and compass functions to guide you on your outdoor adventures. The sleek pink design with a vibrant green dial adds a sporty touch to your wrist.", "Price": 199.00 }, { "Id": 81, "Type": "Footwear", "Brand": "AirStrider", "Name": "Trail Breeze Hiking Shoes", "Description": "Experience the ultimate comfort and stability with the Trail Breeze hiking shoes by AirStrider. These lightweight shoes feature a breathable mesh upper in vivid blue, providing excellent airflow on hot summer hikes. The durable rubber outsole offers exceptional grip, ensuring you stay steady on any terrain.", "Price": 109.99 }, { "Id": 82, "Type": "Ski/boarding", "Brand": "WildRunner", "Name": "Maverick Pro Ski Goggles", "Description": "Conquer the slopes in style with the Maverick Pro ski goggles by WildRunner. Designed for maximum performance, these goggles feature a sleek black frame and mirrored, polarized lenses that reduce glare, enhancing your visibility. With a comfortable foam lining and adjustable strap, these goggles provide a secure and snug fit.", "Price": 139.99 }, { "Id": 83, "Type": "Ski/boarding", "Brand": "Zephyr", "Name": "Blizzard Freestyle Snowboard", "Description": "Unleash your freestyle skills on the slopes with the Blizzard snowboard from Zephyr. Featuring a vibrant orange and black design, this snowboard is perfect for riders who crave speed and control. Constructed with a durable bamboo core and carbon fiber reinforcement, the Blizzard offers an optimal blend of flexibility and responsiveness.", "Price": 379.00 }, { "Id": 84, "Type": "Climbing", "Brand": "Gravitator", "Name": "Gravity Harness", "Description": "Reach new heights with the Gravitator Gravity harness. With its innovative design and sturdy construction, this harness ensures your safety while climbing. The sleek black and red color scheme adds a touch of style. It offers maximum comfort and freedom of movement, giving you the confidence to conquer any climbing challenge.", "Price": 89.99 }, { "Id": 85, "Type": "Trekking", "Brand": "Daybird", "Name": "LumenHead Headlamp", "Description": "Illuminate your outdoor adventures with the Daybird LumenHead headlamp. This compact yet powerful headlamp features a bright LED light in a vibrant green housing. With multiple lighting modes, including a red light for preserving night vision, the LumenHead provides exceptional visibility in any conditions.", "Price": 49.99 }, { "Id": 86, "Type": "Cycling", "Brand": "Raptor Elite", "Name": "ProVent Bike Helmet", "Description": "Stay safe and stylish on your cycling adventures with the Raptor Elite ProVent bike helmet. This sleek helmet features a matte black finish with striking red accents. The ProVent technology ensures optimal airflow, keeping you cool and comfortable. With its adjustable fit system and removable visor, this helmet is perfect for both casual and professional riders.", "Price": 79.99 }, { "Id": 87, "Type": "Trekking", "Brand": "XE", "Name": "Nomad 2-Person Tent", "Description": "Embark on your next camping expedition with the XE Nomad 2-person tent. Designed for rugged outdoor conditions, this tent features a durable waterproof fabric in earthy tones. The spacious interior and easy-to-use setup make it ideal for comfortable camping. With its innovative ventilation system, you'll stay cool and dry throughout the night.", "Price": 229.00 }, { "Id": 88, "Type": "Bags", "Brand": "Green Equipment", "Name": "Alpine AlpinePack Backpack", "Description": "The AlpinePack backpack by Green Equipment is your ultimate companion for outdoor adventures. This versatile and durable backpack features a sleek navy design with reinforced straps. With a capacity of 45 liters, multiple compartments, and a hydration pack sleeve, it offers ample storage and organization. The ergonomic back panel ensures maximum comfort, even on the most challenging treks.", "Price": 129.00 }, { "Id": 89, "Type": "Jackets", "Brand": "Legend", "Name": "Summit Pro Down Jacket", "Description": "Defy the coldest temperatures with the Legend Summit Pro down jacket. This high-performance jacket is filled with premium down insulation for exceptional warmth. The sleek design in deep navy blue is complemented by contrasting silver zippers and emblems. Equipped with weather-resistant fabric and a removable hood, this jacket is your ultimate companion for extreme winter adventures.", "Price": 239.99 }, { "Id": 90, "Type": "Navigation", "Brand": "B&R", "Name": "TrailTracker GPS Watch", "Description": "Navigate the trails like a pro with the B&R TrailTracker GPS watch. This rugged and reliable watch features a built-in GPS that tracks your location, speed, and distance accurately. The sleek camo design with a vivid orange strap adds a sporty touch. With its long battery life and water-resistant construction, you can trust this watch to guide you through any outdoor expedition.", "Price": 199.00 }, { "Id": 91, "Type": "Footwear", "Brand": "WildRunner", "Name": "Trailblazer Trail Running Shoes", "Description": "Conquer any terrain in the Trailblazer Trail Running Shoes by WildRunner. These lightweight shoes come in vibrant blue and feature a rugged outsole for excellent traction, a breathable mesh upper for maximum comfort, and quick-drying materials to keep you dry on your adventures.", "Price": 89.99 }, { "Id": 92, "Type": "Ski/boarding", "Brand": "Daybird", "Name": "Blizzard Snowboard", "Description": "Take on the slopes with the Daybird Blizzard Snowboard. This powerful board features a sleek design in icy white, with a durable wood core, a versatile medium flex, and a precision base that allows for smooth rides and easy turns. Strap on and carve your way to glory.", "Price": 449.99 }, { "Id": 93, "Type": "Climbing", "Brand": "Raptor Elite", "Name": "Summit Climbing Harness", "Description": "Conquer the highest peaks with the Raptor Elite Summit Climbing Harness. This durable and lightweight harness, available in bold red, provides maximum comfort and safety while scaling tricky routes. Its adjustable waistband and leg loops ensure a snug fit, while the gear loops provide easy access to your equipment.", "Price": 109.99 }, { "Id": 94, "Type": "Bags", "Brand": "Gravitator", "Name": "Gravity Hiking Backpack", "Description": "Embark on unforgettable hikes with the Gravitator Gravity Hiking Backpack. Available in tiger stripes, this backpack offers a spacious main compartment, multiple pockets, and a hydration system compatible design. The lightweight and durable construction ensures maximum comfort on the trails.", "Price": 79.99 }, { "Id": 95, "Type": "Cycling", "Brand": "AirStrider", "Name": "AeroLite Cycling Helmet", "Description": "Stay safe and stylish on your cycling adventures with the AirStrider AeroLite Cycling Helmet. This helmet, in a glossy grey, features a lightweight design, adjustable straps, and excellent ventilation to keep you cool. The aerodynamic shape reduces air resistance, enabling you to pick up speed with confidence.", "Price": 129.99 }, { "Id": 96, "Type": "Trekking", "Brand": "B&R", "Name": "Explorer Camping Tent", "Description": "Experience the great outdoors with the B&R Explorer Camping Tent. This spacious tent, available in forest green, comfortably fits up to six people with a separate sleeping area and a generous living space. Its sturdy construction and weather-resistant materials ensure durability and protection from the elements.", "Price": 279.99 }, { "Id": 97, "Type": "Bags", "Brand": "Quester", "Name": "Gravity Waterproof Dry Bag", "Description": "Keep your essentials dry and secure with the Quester Gravity Waterproof Dry Bag. This versatile bag, in vibrant orange, features a roll-top closure system, adjustable shoulder straps, and durable PVC-coated fabric to withstand water, sand, and dirt. Ideal for adventures on land or water.", "Price": 49.99 }, { "Id": 98, "Type": "Jackets", "Brand": "Legend", "Name": "Element Outdoor Jacket", "Description": "Gear up for any adventure with the Legend Element Outdoor Jacket. Available in charcoal gray, this jacket offers ultimate protection with its waterproof and windproof shell. The breathable fabric and adjustable cuffs ensure comfort, allowing you to explore in any weather condition.", "Price": 179.99 }, { "Id": 99, "Type": "Navigation", "Brand": "Solstix", "Name": "Adventurer GPS Watch", "Description": "Take navigation to the next level with the Solstix Adventurer GPS Watch. This sleek and durable watch, in midnight blue, features a built-in GPS, altimeter, and compass, allowing you to track your routes and monitor your progress. With multiple sport modes, it's the ideal companion for outdoor enthusiasts.", "Price": 199.99 }, { "Id": 100, "Type": "Trekking", "Brand": "Green Equipment", "Name": "EcoLite Trekking Poles", "Description": "Tackle challenging trails with the Green Equipment EcoLite Trekking Poles. These lightweight poles, in vibrant green, feature adjustable height, shock-absorbing capabilities, and ergonomic cork handles for a comfortable grip. Whether ascending or descending, these poles provide stability and support.", "Price": 79.99 }, { "Id": 101, "Type": "Footwear", "Brand": "Raptor Elite", "Name": "Trek Xtreme Hiking Shoes", "Description": "The Trek Xtreme hiking shoes by Raptor Elite are built to endure any trail. With their durable leather upper and rugged rubber sole, they offer excellent traction and protection. These shoes come in a timeless brown color that adds a touch of style to your outdoor adventures.", "Price": 135.99 } ] ================================================ FILE: src/Catalog.API/appsettings.Development.json ================================================ { "ConnectionStrings": { "CatalogDB": "Host=localhost;Database=CatalogDB;Username=postgres;Password=yourWeak(!)Password" } } ================================================ FILE: src/Catalog.API/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "OpenApi": { "Endpoint": { "Name": "Catalog.API V1" }, "Document": { "Description": "The Catalog Microservice HTTP API. This is a Data-Driven/CRUD microservice sample", "Title": "eShop - Catalog HTTP API", "Version": "v1" } }, "ConnectionStrings": { "EventBus": "amqp://localhost" }, "EventBus": { "SubscriptionClientName": "Catalog" }, "CatalogOptions": { "UseCustomizationData": false } } ================================================ FILE: src/ClientApp/Animations/Base/AnimationBase.cs ================================================ using System.Diagnostics; namespace eShop.ClientApp.Animations.Base; public abstract class AnimationBase : BindableObject { public static readonly BindableProperty TargetProperty = BindableProperty.Create(nameof(Target), typeof(VisualElement), typeof(AnimationBase), propertyChanged: (bindable, oldValue, newValue) => ((AnimationBase)bindable).Target = (VisualElement)newValue); public static readonly BindableProperty DurationProperty = BindableProperty.Create(nameof(Duration), typeof(string), typeof(AnimationBase), "1000", propertyChanged: (bindable, oldValue, newValue) => ((AnimationBase)bindable).Duration = (string)newValue); public static readonly BindableProperty EasingProperty = BindableProperty.Create(nameof(Easing), typeof(EasingType), typeof(AnimationBase), EasingType.Linear, propertyChanged: (bindable, oldValue, newValue) => ((AnimationBase)bindable).Easing = (EasingType)newValue); public static readonly BindableProperty RepeatForeverProperty = BindableProperty.Create(nameof(RepeatForever), typeof(bool), typeof(AnimationBase), false, propertyChanged: (bindable, oldValue, newValue) => ((AnimationBase)bindable).RepeatForever = (bool)newValue); public static readonly BindableProperty DelayProperty = BindableProperty.Create(nameof(Delay), typeof(int), typeof(AnimationBase), 0, propertyChanged: (bindable, oldValue, newValue) => ((AnimationBase)bindable).Delay = (int)newValue); private bool _isRunning; public VisualElement Target { get => (VisualElement)GetValue(TargetProperty); set => SetValue(TargetProperty, value); } public string Duration { get => (string)GetValue(DurationProperty); set => SetValue(DurationProperty, value); } public EasingType Easing { get => (EasingType)GetValue(EasingProperty); set => SetValue(EasingProperty, value); } public bool RepeatForever { get => (bool)GetValue(RepeatForeverProperty); set => SetValue(RepeatForeverProperty, value); } public int Delay { get => (int)GetValue(DelayProperty); set => SetValue(DelayProperty, value); } protected abstract Task BeginAnimation(); public async Task Begin() { try { if (!_isRunning) { _isRunning = true; await InternalBegin() .ContinueWith(t => t.Exception, TaskContinuationOptions.OnlyOnFaulted) .ConfigureAwait(false); } } catch (TaskCanceledException) { } catch (Exception ex) { Debug.WriteLine($"Exception in animation {ex}"); } } protected abstract Task ResetAnimation(); public async Task Reset() { _isRunning = false; await ResetAnimation(); } private async Task InternalBegin() { if (Delay > 0) { await Task.Delay(Delay); } if (!RepeatForever) { await BeginAnimation(); } else { do { await BeginAnimation(); await ResetAnimation(); } while (RepeatForever); } } } ================================================ FILE: src/ClientApp/Animations/Base/EasingType.cs ================================================ namespace eShop.ClientApp.Animations.Base; public enum EasingType { BounceIn, BounceOut, CubicIn, CubicInOut, CubicOut, Linear, SinIn, SinInOut, SinOut, SpringIn, SpringOut } ================================================ FILE: src/ClientApp/Animations/FadeToAnimation.cs ================================================ using eShop.ClientApp.Animations.Base; using eShop.ClientApp.Helpers; namespace eShop.ClientApp.Animations; public class FadeToAnimation : AnimationBase { public static readonly BindableProperty OpacityProperty = BindableProperty.Create(nameof(Opacity), typeof(double), typeof(FadeToAnimation), 0.0d, propertyChanged: (bindable, oldValue, newValue) => ((FadeToAnimation)bindable).Opacity = (double)newValue); public double Opacity { get => (double)GetValue(OpacityProperty); set => SetValue(OpacityProperty, value); } protected override Task BeginAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } return Target.FadeTo(Opacity, Convert.ToUInt32(Duration), EasingHelper.GetEasing(Easing)); } protected override Task ResetAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } return Target.FadeTo(0, 0); } } public class FadeInAnimation : AnimationBase { public enum FadeDirection { Up, Down } public static readonly BindableProperty DirectionProperty = BindableProperty.Create(nameof(Direction), typeof(FadeDirection), typeof(FadeInAnimation), FadeDirection.Up, propertyChanged: (bindable, oldValue, newValue) => ((FadeInAnimation)bindable).Direction = (FadeDirection)newValue); public FadeDirection Direction { get => (FadeDirection)GetValue(DirectionProperty); set => SetValue(DirectionProperty, value); } protected override Task BeginAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } Target.Dispatcher.Dispatch(() => Target.Animate("FadeIn", FadeIn(), 16, Convert.ToUInt32(Duration))); return Task.CompletedTask; } protected override Task ResetAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } Target.Dispatcher.Dispatch(() => Target.FadeTo(0, 0)); return Task.CompletedTask; } internal Animation FadeIn() { var animation = new Animation(); animation.WithConcurrent(f => Target.Opacity = f, 0, 1, Microsoft.Maui.Easing.CubicOut); animation.WithConcurrent( f => Target.TranslationY = f, Target.TranslationY + (Direction == FadeDirection.Up ? 50 : -50), Target.TranslationY, Microsoft.Maui.Easing.CubicOut); return animation; } } public class FadeOutAnimation : AnimationBase { public enum FadeDirection { Up, Down } public static readonly BindableProperty DirectionProperty = BindableProperty.Create(nameof(Direction), typeof(FadeDirection), typeof(FadeOutAnimation), FadeDirection.Up, propertyChanged: (bindable, oldValue, newValue) => ((FadeOutAnimation)bindable).Direction = (FadeDirection)newValue); public FadeDirection Direction { get => (FadeDirection)GetValue(DirectionProperty); set => SetValue(DirectionProperty, value); } protected override Task BeginAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } Target.Dispatcher.Dispatch(() => Target.Animate("FadeOut", FadeOut(), 16, Convert.ToUInt32(Duration))); return Task.CompletedTask; } protected override Task ResetAnimation() { if (Target == null) { throw new NullReferenceException("Null Target property."); } Target.Dispatcher.Dispatch(() => Target.FadeTo(0, 0)); return Task.CompletedTask; } internal Animation FadeOut() { Animation animation = new(); animation.WithConcurrent( f => Target.Opacity = f, 1, 0); animation.WithConcurrent( f => Target.TranslationY = f, Target.TranslationY, Target.TranslationY + (Direction == FadeDirection.Up ? 50 : -50)); return animation; } } ================================================ FILE: src/ClientApp/Animations/StoryBoard.cs ================================================ using eShop.ClientApp.Animations.Base; namespace eShop.ClientApp.Animations; [ContentProperty("Animations")] public class StoryBoard : AnimationBase { public StoryBoard() { Animations = new List(); } public StoryBoard(List animations) { Animations = animations; } public List Animations { get; } protected override async Task BeginAnimation() { foreach (var animation in Animations) { if (animation.Target == null) { animation.Target = Target; } await animation.Begin(); } } protected override async Task ResetAnimation() { foreach (var animation in Animations) { if (animation.Target == null) { animation.Target = Target; } await animation.Reset(); } } } ================================================ FILE: src/ClientApp/App.xaml ================================================  ================================================ FILE: src/ClientApp/App.xaml.cs ================================================ using System.Diagnostics; using System.Globalization; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Location; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.Services.Theme; using Location = eShop.ClientApp.Models.Location.Location; namespace eShop.ClientApp; public partial class App : Application { private readonly IAppEnvironmentService _appEnvironmentService; private readonly ILocationService _locationService; private readonly INavigationService _navigationService; private readonly ISettingsService _settingsService; private readonly ITheme _theme; public App( ISettingsService settingsService, IAppEnvironmentService appEnvironmentService, INavigationService navigationService, ILocationService locationService, ITheme theme) { _settingsService = settingsService; _appEnvironmentService = appEnvironmentService; _navigationService = navigationService; _locationService = locationService; _theme = theme; InitializeComponent(); InitApp(); Current.UserAppTheme = AppTheme.Light; } protected override Window CreateWindow(IActivationState activationState) { return new Window(new AppShell(_navigationService)); } private void InitApp() { if (VersionTracking.IsFirstLaunchEver) { _settingsService.UseMocks = true; } if (!_settingsService.UseMocks) { _appEnvironmentService.UpdateDependencies(_settingsService.UseMocks); } } protected override async void OnStart() { base.OnStart(); if (_settingsService.AllowGpsLocation && !_settingsService.UseFakeLocation) { await GetGpsLocation(); } if (!_settingsService.UseMocks) { await SendCurrentLocation(); } OnResume(); } protected override void OnSleep() { SetStatusBar(); RequestedThemeChanged -= App_RequestedThemeChanged; } protected override void OnResume() { SetStatusBar(); RequestedThemeChanged += App_RequestedThemeChanged; } private void App_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) { Dispatcher.Dispatch(() => SetStatusBar()); } private void SetStatusBar() { var nav = Windows[0].Page as NavigationPage; if (Current.RequestedTheme == AppTheme.Dark) { _theme?.SetStatusBarColor(Colors.Black, false); if (nav != null) { nav.BarBackgroundColor = Colors.Black; nav.BarTextColor = Colors.White; } } else { _theme?.SetStatusBarColor(Colors.White, true); if (nav != null) { nav.BarBackgroundColor = Colors.White; nav.BarTextColor = Colors.Black; } } } private async Task GetGpsLocation() { try { var request = new GeolocationRequest(GeolocationAccuracy.High); var location = await Geolocation.GetLocationAsync(request, CancellationToken.None).ConfigureAwait(false); if (location != null) { _settingsService.Latitude = location.Latitude.ToString(); _settingsService.Longitude = location.Longitude.ToString(); } } catch (Exception ex) { if (ex is FeatureNotEnabledException || ex is FeatureNotEnabledException || ex is PermissionException) { _settingsService.AllowGpsLocation = false; } // Unable to get location Debug.WriteLine(ex); } } private async Task SendCurrentLocation() { var location = new Location { Latitude = double.Parse(_settingsService.Latitude, CultureInfo.InvariantCulture), Longitude = double.Parse(_settingsService.Longitude, CultureInfo.InvariantCulture) }; await _locationService.UpdateUserLocation(location); } public static void HandleAppActions(AppAction appAction) { if (Current is not App app) { return; } app.Dispatcher.Dispatch( async () => { if (appAction.Id.Equals(AppActions.ViewProfileAction.Id)) { await app._navigationService.NavigateToAsync("//Main/Profile"); } }); } } ================================================ FILE: src/ClientApp/AppActions.cs ================================================ namespace eShop.ClientApp; public static class AppActions { public static readonly AppAction ViewProfileAction = new("view_profile", "View Profile", "View your user profile"); } ================================================ FILE: src/ClientApp/AppShell.xaml ================================================  ================================================ FILE: src/ClientApp/Services/AppEnvironment/AppEnvironmentService.cs ================================================ using eShop.ClientApp.Services.Basket; using eShop.ClientApp.Services.Catalog; using eShop.ClientApp.Services.Identity; using eShop.ClientApp.Services.Order; namespace eShop.ClientApp.Services.AppEnvironment; public class AppEnvironmentService : IAppEnvironmentService { private readonly IBasketService _basketService; private readonly ICatalogService _catalogService; private readonly IIdentityService _identityService; private readonly IBasketService _mockBasketService; private readonly ICatalogService _mockCatalogService; private readonly IIdentityService _mockIdentityService; private readonly IOrderService _mockOrderService; private readonly IOrderService _orderService; public AppEnvironmentService( IBasketService mockBasketService, IBasketService basketService, ICatalogService mockCatalogService, ICatalogService catalogService, IOrderService mockOrderService, IOrderService orderService, IIdentityService mockIdentityService, IIdentityService identityService) { _mockBasketService = mockBasketService; _basketService = basketService; _mockCatalogService = mockCatalogService; _catalogService = catalogService; _mockOrderService = mockOrderService; _orderService = orderService; _mockIdentityService = mockIdentityService; _identityService = identityService; } public IBasketService BasketService { get; private set; } public ICatalogService CatalogService { get; private set; } public IOrderService OrderService { get; private set; } public IIdentityService IdentityService { get; private set; } public void UpdateDependencies(bool useMockServices) { if (useMockServices) { BasketService = _mockBasketService; CatalogService = _mockCatalogService; OrderService = _mockOrderService; IdentityService = _mockIdentityService; } else { BasketService = _basketService; CatalogService = _catalogService; OrderService = _orderService; IdentityService = _identityService; } } } ================================================ FILE: src/ClientApp/Services/AppEnvironment/IAppEnvironmentService.cs ================================================ using eShop.ClientApp.Services.Basket; using eShop.ClientApp.Services.Catalog; using eShop.ClientApp.Services.Identity; using eShop.ClientApp.Services.Order; namespace eShop.ClientApp.Services.AppEnvironment; public interface IAppEnvironmentService { IBasketService BasketService { get; } ICatalogService CatalogService { get; } IOrderService OrderService { get; } IIdentityService IdentityService { get; } void UpdateDependencies(bool useMockServices); } ================================================ FILE: src/ClientApp/Services/Basket/BasketMockService.cs ================================================ using eShop.ClientApp.Models.Basket; namespace eShop.ClientApp.Services.Basket; public class BasketMockService : IBasketService { private CustomerBasket _mockCustomBasket; public BasketMockService() { _mockCustomBasket = new CustomerBasket {BuyerId = "9245fe4a-d402-451c-b9ed-9c1a04247482"}; _mockCustomBasket.AddItemToBasket(new BasketItem { Id = "1", PictureUrl = "fake_product_01.png", ProductId = Common.Common.MockCatalogItemId01, ProductName = ".NET Bot Blue Sweatshirt (M)", Quantity = 1, UnitPrice = 19.50M }); _mockCustomBasket.AddItemToBasket(new BasketItem { Id = "2", PictureUrl = "fake_product_04.png", ProductId = Common.Common.MockCatalogItemId04, ProductName = ".NET Black Cup", Quantity = 1, UnitPrice = 17.00M }); } public IEnumerable LocalBasketItems { get; set; } public async Task GetBasketAsync() { await Task.Delay(10); return _mockCustomBasket; } public async Task UpdateBasketAsync(CustomerBasket customerBasket) { await Task.Delay(10); _mockCustomBasket = customerBasket; return _mockCustomBasket; } public async Task ClearBasketAsync() { await Task.Delay(10); _mockCustomBasket.ClearBasket(); LocalBasketItems = null; } } ================================================ FILE: src/ClientApp/Services/Basket/BasketService.cs ================================================ using eShop.ClientApp.BasketGrpcClient; using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Services.FixUri; using eShop.ClientApp.Services.Identity; using eShop.ClientApp.Services.Settings; using Google.Protobuf; using Grpc.Core; using Grpc.Net.Client; using BasketItem = eShop.ClientApp.Models.Basket.BasketItem; namespace eShop.ClientApp.Services.Basket; public class BasketService : IBasketService, IDisposable { private readonly IFixUriService _fixUriService; private readonly IIdentityService _identityService; private readonly ISettingsService _settingsService; private BasketGrpcClient.Basket.BasketClient _basketClient; private GrpcChannel _channel; public BasketService(IIdentityService identityService, ISettingsService settingsService, IFixUriService fixUriService) { _identityService = identityService; _settingsService = settingsService; _fixUriService = fixUriService; } public IEnumerable LocalBasketItems { get; set; } public async Task GetBasketAsync() { CustomerBasket basket = new(); var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return basket; } try { var basketResponse = await GetBasketClient() .GetBasketAsync(new GetBasketRequest(), CreateAuthenticationHeaders(authToken)); if (basketResponse.IsInitialized() && basketResponse.Items.Any()) { foreach (var item in basketResponse.Items) { basket.AddItemToBasket(new BasketItem {ProductId = item.ProductId, Quantity = item.Quantity}); } } } catch (Exception exception) { Console.WriteLine(exception); basket = null; } _fixUriService.FixBasketItemPictureUri(basket?.Items); return basket; } public async Task UpdateBasketAsync(CustomerBasket customerBasket) { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return customerBasket; } var updateBasketRequest = new UpdateBasketRequest(); updateBasketRequest.Items.Add( customerBasket.Items .Select( x => new BasketGrpcClient.BasketItem {ProductId = x.ProductId, Quantity = x.Quantity})); var result = await GetBasketClient() .UpdateBasketAsync(updateBasketRequest, CreateAuthenticationHeaders(authToken)).ConfigureAwait(false); if (result.Items.Count > 0) { customerBasket.ClearBasket(); } foreach (var item in result.Items) { customerBasket.AddItemToBasket(new BasketItem {ProductId = item.ProductId, Quantity = item.Quantity}); } return customerBasket; } public async Task ClearBasketAsync() { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return; } await GetBasketClient().DeleteBasketAsync(new DeleteBasketRequest(), CreateAuthenticationHeaders(authToken)) .ConfigureAwait(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } private BasketGrpcClient.Basket.BasketClient GetBasketClient() { if (_basketClient is not null) { return _basketClient; } _channel = GrpcChannel.ForAddress(_settingsService.GatewayBasketEndpointBase); _basketClient = new BasketGrpcClient.Basket.BasketClient(_channel); return _basketClient; } private Metadata CreateAuthenticationHeaders(string token) { var headers = new Metadata(); headers.Add("authorization", $"Bearer {token}"); return headers; } protected virtual void Dispose(bool disposing) { if (disposing) { _channel?.Dispose(); } } ~BasketService() { Dispose(false); } } ================================================ FILE: src/ClientApp/Services/Basket/IBasketService.cs ================================================ using eShop.ClientApp.Models.Basket; namespace eShop.ClientApp.Services.Basket; public interface IBasketService { IEnumerable LocalBasketItems { get; set; } Task GetBasketAsync(); Task UpdateBasketAsync(CustomerBasket customerBasket); Task ClearBasketAsync(); } ================================================ FILE: src/ClientApp/Services/Basket/Protos/Basket.cs ================================================ // // Generated by the protocol buffer compiler. DO NOT EDIT! // source: Services/Basket/Protos/basket.proto // #pragma warning disable 1591, 0612, 3021, 8981 #region Designer generated code using pb = global::Google.Protobuf; using pbc = global::Google.Protobuf.Collections; using pbr = global::Google.Protobuf.Reflection; using scg = global::System.Collections.Generic; namespace eShop.ClientApp.BasketGrpcClient { /// Holder for reflection information generated from Services/Basket/Protos/basket.proto public static partial class BasketReflection { #region Descriptor /// File descriptor for Services/Basket/Protos/basket.proto public static pbr::FileDescriptor Descriptor { get { return descriptor; } } private static pbr::FileDescriptor descriptor; static BasketReflection() { byte[] descriptorData = global::System.Convert.FromBase64String( string.Concat( "CiNTZXJ2aWNlcy9CYXNrZXQvUHJvdG9zL2Jhc2tldC5wcm90bxIJQmFza2V0", "QXBpIhIKEEdldEJhc2tldFJlcXVlc3QiPgoWQ3VzdG9tZXJCYXNrZXRSZXNw", "b25zZRIkCgVpdGVtcxgBIAMoCzIVLkJhc2tldEFwaS5CYXNrZXRJdGVtIjIK", "CkJhc2tldEl0ZW0SEgoKcHJvZHVjdF9pZBgCIAEoBRIQCghxdWFudGl0eRgG", "IAEoBSI7ChNVcGRhdGVCYXNrZXRSZXF1ZXN0EiQKBWl0ZW1zGAIgAygLMhUu", "QmFza2V0QXBpLkJhc2tldEl0ZW0iFQoTRGVsZXRlQmFza2V0UmVxdWVzdCIW", "ChREZWxldGVCYXNrZXRSZXNwb25zZTL/AQoGQmFza2V0Ek0KCUdldEJhc2tl", "dBIbLkJhc2tldEFwaS5HZXRCYXNrZXRSZXF1ZXN0GiEuQmFza2V0QXBpLkN1", "c3RvbWVyQmFza2V0UmVzcG9uc2UiABJTCgxVcGRhdGVCYXNrZXQSHi5CYXNr", "ZXRBcGkuVXBkYXRlQmFza2V0UmVxdWVzdBohLkJhc2tldEFwaS5DdXN0b21l", "ckJhc2tldFJlc3BvbnNlIgASUQoMRGVsZXRlQmFza2V0Eh4uQmFza2V0QXBp", "LkRlbGV0ZUJhc2tldFJlcXVlc3QaHy5CYXNrZXRBcGkuRGVsZXRlQmFza2V0", "UmVzcG9uc2UiAEIjqgIgZVNob3AuQ2xpZW50QXBwLkJhc2tldEdycGNDbGll", "bnRiBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest), global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse), global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse.Parser, new[]{ "Items" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.BasketItem), global::eShop.ClientApp.BasketGrpcClient.BasketItem.Parser, new[]{ "ProductId", "Quantity" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest), global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest.Parser, new[]{ "Items" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest), global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketResponse), global::eShop.ClientApp.BasketGrpcClient.DeleteBasketResponse.Parser, null, null, null, null, null) })); } #endregion } #region Messages [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class GetBasketRequest : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new GetBasketRequest()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[0]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public GetBasketRequest() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public GetBasketRequest(GetBasketRequest other) : this() { _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public GetBasketRequest Clone() { return new GetBasketRequest(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as GetBasketRequest); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(GetBasketRequest other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(GetBasketRequest other) { if (other == null) { return; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; } } } #endif } [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class CustomerBasketResponse : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new CustomerBasketResponse()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[1]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public CustomerBasketResponse() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public CustomerBasketResponse(CustomerBasketResponse other) : this() { items_ = other.items_.Clone(); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public CustomerBasketResponse Clone() { return new CustomerBasketResponse(this); } /// Field number for the "items" field. public const int ItemsFieldNumber = 1; private static readonly pb::FieldCodec _repeated_items_codec = pb::FieldCodec.ForMessage(10, global::eShop.ClientApp.BasketGrpcClient.BasketItem.Parser); private readonly pbc::RepeatedField items_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public pbc::RepeatedField Items { get { return items_; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as CustomerBasketResponse); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(CustomerBasketResponse other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } if(!items_.Equals(other.items_)) return false; return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; hash ^= items_.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else items_.WriteTo(output, _repeated_items_codec); if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { items_.WriteTo(ref output, _repeated_items_codec); if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; size += items_.CalculateSize(_repeated_items_codec); if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(CustomerBasketResponse other) { if (other == null) { return; } items_.Add(other.items_); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 10: { items_.AddEntriesFrom(input, _repeated_items_codec); break; } } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 10: { items_.AddEntriesFrom(ref input, _repeated_items_codec); break; } } } } #endif } [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class BasketItem : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new BasketItem()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[2]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public BasketItem() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public BasketItem(BasketItem other) : this() { productId_ = other.productId_; quantity_ = other.quantity_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public BasketItem Clone() { return new BasketItem(this); } /// Field number for the "product_id" field. public const int ProductIdFieldNumber = 2; private int productId_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int ProductId { get { return productId_; } set { productId_ = value; } } /// Field number for the "quantity" field. public const int QuantityFieldNumber = 6; private int quantity_; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int Quantity { get { return quantity_; } set { quantity_ = value; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as BasketItem); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(BasketItem other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } if (ProductId != other.ProductId) return false; if (Quantity != other.Quantity) return false; return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; if (ProductId != 0) hash ^= ProductId.GetHashCode(); if (Quantity != 0) hash ^= Quantity.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else if (ProductId != 0) { output.WriteRawTag(16); output.WriteInt32(ProductId); } if (Quantity != 0) { output.WriteRawTag(48); output.WriteInt32(Quantity); } if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { if (ProductId != 0) { output.WriteRawTag(16); output.WriteInt32(ProductId); } if (Quantity != 0) { output.WriteRawTag(48); output.WriteInt32(Quantity); } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; if (ProductId != 0) { size += 1 + pb::CodedOutputStream.ComputeInt32Size(ProductId); } if (Quantity != 0) { size += 1 + pb::CodedOutputStream.ComputeInt32Size(Quantity); } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(BasketItem other) { if (other == null) { return; } if (other.ProductId != 0) { ProductId = other.ProductId; } if (other.Quantity != 0) { Quantity = other.Quantity; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 16: { ProductId = input.ReadInt32(); break; } case 48: { Quantity = input.ReadInt32(); break; } } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 16: { ProductId = input.ReadInt32(); break; } case 48: { Quantity = input.ReadInt32(); break; } } } } #endif } [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class UpdateBasketRequest : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new UpdateBasketRequest()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[3]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public UpdateBasketRequest() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public UpdateBasketRequest(UpdateBasketRequest other) : this() { items_ = other.items_.Clone(); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public UpdateBasketRequest Clone() { return new UpdateBasketRequest(this); } /// Field number for the "items" field. public const int ItemsFieldNumber = 2; private static readonly pb::FieldCodec _repeated_items_codec = pb::FieldCodec.ForMessage(18, global::eShop.ClientApp.BasketGrpcClient.BasketItem.Parser); private readonly pbc::RepeatedField items_ = new pbc::RepeatedField(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public pbc::RepeatedField Items { get { return items_; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as UpdateBasketRequest); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(UpdateBasketRequest other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } if(!items_.Equals(other.items_)) return false; return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; hash ^= items_.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else items_.WriteTo(output, _repeated_items_codec); if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { items_.WriteTo(ref output, _repeated_items_codec); if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; size += items_.CalculateSize(_repeated_items_codec); if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(UpdateBasketRequest other) { if (other == null) { return; } items_.Add(other.items_); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 18: { items_.AddEntriesFrom(input, _repeated_items_codec); break; } } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 18: { items_.AddEntriesFrom(ref input, _repeated_items_codec); break; } } } } #endif } [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class DeleteBasketRequest : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new DeleteBasketRequest()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[4]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketRequest() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketRequest(DeleteBasketRequest other) : this() { _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketRequest Clone() { return new DeleteBasketRequest(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as DeleteBasketRequest); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(DeleteBasketRequest other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(DeleteBasketRequest other) { if (other == null) { return; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; } } } #endif } [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class DeleteBasketResponse : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new DeleteBasketResponse()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.MessageTypes[5]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] pbr::MessageDescriptor pb::IMessage.Descriptor { get { return Descriptor; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketResponse() { OnConstruction(); } partial void OnConstruction(); [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketResponse(DeleteBasketResponse other) : this() { _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DeleteBasketResponse Clone() { return new DeleteBasketResponse(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { return Equals(other as DeleteBasketResponse); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public bool Equals(DeleteBasketResponse other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } return Equals(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } return hash; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override string ToString() { return pb::JsonFormatter.ToDiagnosticString(this); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void WriteTo(pb::CodedOutputStream output) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else if (_unknownFields != null) { _unknownFields.WriteTo(output); } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } } #endif [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } return size; } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(DeleteBasketResponse other) { if (other == null) { return; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public void MergeFrom(pb::CodedInputStream input) { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE input.ReadRawMessage(this); #else uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; } } #endif } #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { if ((tag & 7) == 4) { // Abort on any end group tag. return; } switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; } } } #endif } #endregion } #endregion Designer generated code ================================================ FILE: src/ClientApp/Services/Basket/Protos/BasketGrpc.cs ================================================ // // Generated by the protocol buffer compiler. DO NOT EDIT! // source: Services/Basket/Protos/basket.proto // #pragma warning disable 0414, 1591, 8981, 0612 #region Designer generated code using grpc = global::Grpc.Core; namespace eShop.ClientApp.BasketGrpcClient { public static partial class Basket { static readonly string __ServiceName = "BasketApi.Basket"; [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context) { #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION if (message is global::Google.Protobuf.IBufferMessage) { context.SetPayloadLength(message.CalculateSize()); global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter()); context.Complete(); return; } #endif context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static class __Helper_MessageCache { public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static T __Helper_DeserializeMessage(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser parser) where T : global::Google.Protobuf.IMessage { #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION if (__Helper_MessageCache.IsBufferMessage) { return parser.ParseFrom(context.PayloadAsReadOnlySequence()); } #endif return parser.ParseFrom(context.PayloadAsNewBuffer()); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_BasketApi_GetBasketRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_BasketApi_CustomerBasketResponse = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_BasketApi_UpdateBasketRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_BasketApi_DeleteBasketRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_BasketApi_DeleteBasketResponse = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::eShop.ClientApp.BasketGrpcClient.DeleteBasketResponse.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Method __Method_GetBasket = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "GetBasket", __Marshaller_BasketApi_GetBasketRequest, __Marshaller_BasketApi_CustomerBasketResponse); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Method __Method_UpdateBasket = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "UpdateBasket", __Marshaller_BasketApi_UpdateBasketRequest, __Marshaller_BasketApi_CustomerBasketResponse); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Method __Method_DeleteBasket = new grpc::Method( grpc::MethodType.Unary, __ServiceName, "DeleteBasket", __Marshaller_BasketApi_DeleteBasketRequest, __Marshaller_BasketApi_DeleteBasketResponse); /// Service descriptor public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor { get { return global::eShop.ClientApp.BasketGrpcClient.BasketReflection.Descriptor.Services[0]; } } /// Client for Basket public partial class BasketClient : grpc::ClientBase { /// Creates a new client for Basket /// The channel to use to make remote calls. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public BasketClient(grpc::ChannelBase channel) : base(channel) { } /// Creates a new client for Basket that uses a custom CallInvoker. /// The callInvoker to use to make remote calls. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public BasketClient(grpc::CallInvoker callInvoker) : base(callInvoker) { } /// Protected parameterless constructor to allow creation of test doubles. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] protected BasketClient() : base() { } /// Protected constructor to allow creation of configured clients. /// The client configuration. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] protected BasketClient(ClientBaseConfiguration configuration) : base(configuration) { } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse GetBasket(global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return GetBasket(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse GetBasket(global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_GetBasket, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall GetBasketAsync(global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return GetBasketAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall GetBasketAsync(global::eShop.ClientApp.BasketGrpcClient.GetBasketRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_GetBasket, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse UpdateBasket(global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return UpdateBasket(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.CustomerBasketResponse UpdateBasket(global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_UpdateBasket, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall UpdateBasketAsync(global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return UpdateBasketAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall UpdateBasketAsync(global::eShop.ClientApp.BasketGrpcClient.UpdateBasketRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_UpdateBasket, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.DeleteBasketResponse DeleteBasket(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return DeleteBasket(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual global::eShop.ClientApp.BasketGrpcClient.DeleteBasketResponse DeleteBasket(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest request, grpc::CallOptions options) { return CallInvoker.BlockingUnaryCall(__Method_DeleteBasket, null, options, request); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall DeleteBasketAsync(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { return DeleteBasketAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] public virtual grpc::AsyncUnaryCall DeleteBasketAsync(global::eShop.ClientApp.BasketGrpcClient.DeleteBasketRequest request, grpc::CallOptions options) { return CallInvoker.AsyncUnaryCall(__Method_DeleteBasket, null, options, request); } /// Creates a new instance of client from given ClientBaseConfiguration. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] protected override BasketClient NewInstance(ClientBaseConfiguration configuration) { return new BasketClient(configuration); } } } } #endregion ================================================ FILE: src/ClientApp/Services/Basket/Protos/basket.proto ================================================ syntax = "proto3"; option csharp_namespace = "eShop.ClientApp.BasketGrpcClient"; package BasketApi; service Basket { rpc GetBasket(GetBasketRequest) returns (CustomerBasketResponse) {} rpc UpdateBasket(UpdateBasketRequest) returns (CustomerBasketResponse) {} rpc DeleteBasket(DeleteBasketRequest) returns (DeleteBasketResponse) {} } message GetBasketRequest { } message CustomerBasketResponse { repeated BasketItem items = 1; } message BasketItem { int32 product_id = 2; int32 quantity = 6; } message UpdateBasketRequest { repeated BasketItem items = 2; } message DeleteBasketRequest { } message DeleteBasketResponse { } ================================================ FILE: src/ClientApp/Services/Catalog/CatalogMockService.cs ================================================ using eShop.ClientApp.Models.Catalog; namespace eShop.ClientApp.Services.Catalog; public class CatalogMockService : ICatalogService { private static readonly List MockCatalogBrands = new() {new CatalogBrand {Id = 1, Brand = "Azure"}, new CatalogBrand {Id = 2, Brand = "Visual Studio"}}; private static readonly List MockCatalogTypes = new() {new CatalogType {Id = 1, Type = "Mug"}, new CatalogType {Id = 2, Type = "T-Shirt"}}; private static readonly List MockCatalog = new() { new CatalogItem { Id = Common.Common.MockCatalogItemId01, PictureUri = "fake_product_01.png", Name = "Adventurer GPS Watch", Price = 199.99M, CatalogBrandId = 2, CatalogBrand = MockCatalogBrands[1], CatalogTypeId = 2, CatalogType = MockCatalogTypes[1], Description = "Navigate with confidence using the Adventurer GPS Watch by Adventurer. This rugged and durable watch features a built-in GPS, altimeter, and compass, allowing you to track your progress and find your way in any terrain. With its sleek black design and easy-to-read display, this watch is both stylish and practical. The Adventurer GPS Watch is a must-have for every adventurer." }, new CatalogItem { Id = Common.Common.MockCatalogItemId02, PictureUri = "fake_product_02.png", Name = "AeroLite Cycling Helmet", Price = 129.99M, CatalogBrandId = 2, CatalogBrand = MockCatalogBrands[1], CatalogTypeId = 2, CatalogType = MockCatalogTypes[1], Description = "Stay safe on your cycling adventures with the Trailblazer Bike Helmet by Green Equipment. This lightweight and durable helmet features an adjustable fit system and ventilation for added comfort. With its vibrant green color and sleek design, you'll stand out on the road. The Trailblazer Bike Helmet is perfect for all types of cycling, from mountain biking to road cycling." }, new CatalogItem { Id = Common.Common.MockCatalogItemId03, PictureUri = "fake_product_03.png", Name = "Alpine AlpinePack Backpack", Price = 129.00M, CatalogBrandId = 2, CatalogBrand = MockCatalogBrands[1], CatalogTypeId = 2, CatalogType = MockCatalogTypes[1], Description = "The AlpinePack backpack by Green Equipment is your ultimate companion for outdoor adventures. This versatile and durable backpack features a sleek navy design with reinforced straps. With a capacity of 45 liters, multiple compartments, and a hydration pack sleeve, it offers ample storage and organization. The ergonomic back panel ensures maximum comfort, even on the most challenging treks." }, new CatalogItem { Id = Common.Common.MockCatalogItemId04, PictureUri = "fake_product_04.png", Name = "Alpine Fusion Goggles", Price = 79.99M, CatalogBrandId = 2, CatalogBrand = MockCatalogBrands[1], CatalogTypeId = 1, CatalogType = MockCatalogTypes[0], Description = "Enhance your skiing experience with the Alpine Fusion Goggles from WildRunner. These goggles offer full UV protection and anti-fog lenses to keep your vision clear on the slopes. With their stylish silver frame and orange lenses, you'll stand out from the crowd. Adjustable straps ensure a secure fit, while the soft foam padding provides comfort all day long." }, new CatalogItem { Id = Common.Common.MockCatalogItemId05, PictureUri = "fake_product_05.png", Name = "Alpine PeakDown Jacket", Price = 249.99M, CatalogBrandId = 1, CatalogBrand = MockCatalogBrands[0], CatalogTypeId = 2, CatalogType = MockCatalogTypes[1], Description = "The Solstix Alpine Peak Down Jacket is crafted for extreme cold conditions. With its bold red color and sleek design, this jacket combines style with functionality. Made with high-quality goose down insulation, the Alpine Peak Jacket provides exceptional warmth and comfort. The jacket features a removable hood, adjustable cuffs, and multiple zippered pockets for storage. Conquer the harshest weather with the Solstix Alpine Peak Down Jacket." } }; public async Task> GetCatalogAsync() { await Task.Delay(10); return MockCatalog; } public async Task GetCatalogItemAsync(int catalogItemId) { await Task.Delay(10); return MockCatalog.FirstOrDefault(x => x.Id == catalogItemId); } public async Task> FilterAsync(int catalogBrandId, int catalogTypeId) { await Task.Delay(10); return MockCatalog .Where( c => c.CatalogBrandId == catalogBrandId && c.CatalogTypeId == catalogTypeId) .ToArray(); } public async Task> GetCatalogBrandAsync() { await Task.Delay(10); return MockCatalogBrands; } public async Task> GetCatalogTypeAsync() { await Task.Delay(10); return MockCatalogTypes; } } ================================================ FILE: src/ClientApp/Services/Catalog/CatalogService.cs ================================================ using eShop.ClientApp.Helpers; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Services.FixUri; using eShop.ClientApp.Services.RequestProvider; using eShop.ClientApp.Services.Settings; namespace eShop.ClientApp.Services.Catalog; public class CatalogService : ICatalogService { private const string ApiUrlBase = "api/catalog"; private const string ApiVersion = "api-version=2.0"; private readonly IFixUriService _fixUriService; private readonly IRequestProvider _requestProvider; private readonly ISettingsService _settingsService; public CatalogService(ISettingsService settingsService, IRequestProvider requestProvider, IFixUriService fixUriService) { _settingsService = settingsService; _requestProvider = requestProvider; _fixUriService = fixUriService; } public async Task> FilterAsync(int catalogBrandId, int catalogTypeId) { var uri = UriHelper.CombineUri(_settingsService.GatewayCatalogEndpointBase, $"{ApiUrlBase}//items?type={catalogTypeId}&brand={catalogBrandId}&PageSize=100&PageIndex=0&{ApiVersion}"); var catalog = await _requestProvider.GetAsync(uri).ConfigureAwait(false); return catalog?.Data ?? Enumerable.Empty(); } public async Task> GetCatalogAsync() { var uri = UriHelper.CombineUri(_settingsService.GatewayCatalogEndpointBase, $"{ApiUrlBase}/items?PageSize=100&{ApiVersion}"); var catalog = await _requestProvider.GetAsync(uri).ConfigureAwait(false); if (catalog?.Data != null) { _fixUriService.FixCatalogItemPictureUri(catalog.Data); return catalog.Data; } return Enumerable.Empty(); } public async Task GetCatalogItemAsync(int catalogItemId) { var uri = UriHelper.CombineUri(_settingsService.GatewayCatalogEndpointBase, $"{ApiUrlBase}/items/{catalogItemId}?{ApiVersion}"); var catalogItem = await _requestProvider.GetAsync(uri).ConfigureAwait(false); if (catalogItem != null) { _fixUriService.FixCatalogItemPictureUri(new[] {catalogItem}); return catalogItem; } return default; } public async Task> GetCatalogBrandAsync() { var uri = UriHelper.CombineUri(_settingsService.GatewayCatalogEndpointBase, $"{ApiUrlBase}/catalogbrands?{ApiVersion}"); var brands = await _requestProvider.GetAsync>(uri).ConfigureAwait(false); return brands?.ToArray() ?? Enumerable.Empty(); } public async Task> GetCatalogTypeAsync() { var uri = UriHelper.CombineUri(_settingsService.GatewayCatalogEndpointBase, $"{ApiUrlBase}/catalogtypes?{ApiVersion}"); var types = await _requestProvider.GetAsync>(uri).ConfigureAwait(false); return types?.ToArray() ?? Enumerable.Empty(); } } ================================================ FILE: src/ClientApp/Services/Catalog/ICatalogService.cs ================================================ using eShop.ClientApp.Models.Catalog; namespace eShop.ClientApp.Services.Catalog; public interface ICatalogService { Task> GetCatalogBrandAsync(); Task> FilterAsync(int catalogBrandId, int catalogTypeId); Task> GetCatalogTypeAsync(); Task> GetCatalogAsync(); Task GetCatalogItemAsync(int catalogItemId); } ================================================ FILE: src/ClientApp/Services/Common/Common.cs ================================================ namespace eShop.ClientApp.Services.Common; public static class Common { public static int MockCatalogItemId01 = 1; public static int MockCatalogItemId02 = 2; public static int MockCatalogItemId03 = 3; public static int MockCatalogItemId04 = 4; public static int MockCatalogItemId05 = 5; public static int MockCampaignId01 = 1; public static int MockCampaignId02 = 2; } ================================================ FILE: src/ClientApp/Services/Dialog/DialogService.cs ================================================ namespace eShop.ClientApp.Services; public class DialogService : IDialogService { public Task ShowAlertAsync(string message, string title, string buttonLabel) { return AppShell.Current.DisplayAlert(title, message, buttonLabel); } } ================================================ FILE: src/ClientApp/Services/Dialog/IDialogService.cs ================================================ namespace eShop.ClientApp.Services; public interface IDialogService { Task ShowAlertAsync(string message, string title, string buttonLabel); } ================================================ FILE: src/ClientApp/Services/EShopJsonSerializerContext.cs ================================================ using System.Text.Json.Serialization; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Models.Token; namespace eShop.ClientApp.Services; [JsonSourceGenerationOptions( PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString)] [JsonSerializable(typeof(CancelOrderCommand))] [JsonSerializable(typeof(CatalogBrand))] [JsonSerializable(typeof(CatalogItem))] [JsonSerializable(typeof(CatalogRoot))] [JsonSerializable(typeof(CatalogType))] [JsonSerializable(typeof(Models.Orders.Order))] [JsonSerializable(typeof(Models.Location.Location))] [JsonSerializable(typeof(UserToken))] internal partial class EShopJsonSerializerContext : JsonSerializerContext { } ================================================ FILE: src/ClientApp/Services/FixUri/FixUriService.cs ================================================ using System.Diagnostics; using System.Text.RegularExpressions; using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Models.Marketing; using eShop.ClientApp.Services.Settings; namespace eShop.ClientApp.Services.FixUri; public class FixUriService : IFixUriService { private const string ApiVersion = "api-version=2.0"; private readonly ISettingsService _settingsService; private readonly Regex IpRegex = new(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"); public FixUriService(ISettingsService settingsService) { _settingsService = settingsService; } public void FixCatalogItemPictureUri(IEnumerable catalogItems) { if (catalogItems is null) { return; } try { if (!_settingsService.UseMocks && _settingsService.GatewayCatalogEndpointBase != _settingsService.DefaultEndpoint) { foreach (var catalogItem in catalogItems) { catalogItem.PictureUri = Path.Combine(_settingsService.GatewayCatalogEndpointBase, $"api/catalog/items/{catalogItem.Id}/pic?{ApiVersion}"); } } } catch (Exception ex) { Debug.WriteLine(ex.Message); } } public void FixBasketItemPictureUri(IEnumerable basketItems) { if (basketItems is null) { return; } try { if (!_settingsService.UseMocks && _settingsService.IdentityEndpointBase != _settingsService.DefaultEndpoint) { foreach (var basketItem in basketItems) { var serverResult = IpRegex.Matches(basketItem.PictureUrl); var localResult = IpRegex.Matches(_settingsService.IdentityEndpointBase); if (serverResult.Count != -1 && localResult.Count != -1) { var serviceIp = serverResult[0].Value; var localIp = localResult[0].Value; basketItem.PictureUrl = basketItem.PictureUrl.Replace(serviceIp, localIp); } } } } catch (Exception ex) { Debug.WriteLine(ex.Message); } } public void FixCampaignItemPictureUri(IEnumerable campaignItems) { if (campaignItems is null) { return; } try { if (!_settingsService.UseMocks && _settingsService.IdentityEndpointBase != _settingsService.DefaultEndpoint) { foreach (var campaignItem in campaignItems) { var serverResult = IpRegex.Matches(campaignItem.PictureUri); var localResult = IpRegex.Matches(_settingsService.IdentityEndpointBase); if (serverResult.Count != -1 && localResult.Count != -1) { var serviceIp = serverResult[0].Value; var localIp = localResult[0].Value; campaignItem.PictureUri = campaignItem.PictureUri.Replace(serviceIp, localIp); } } } } catch (Exception ex) { Debug.WriteLine(ex.Message); } } } ================================================ FILE: src/ClientApp/Services/FixUri/IFixUriService.cs ================================================ using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Models.Marketing; namespace eShop.ClientApp.Services.FixUri; public interface IFixUriService { void FixCatalogItemPictureUri(IEnumerable catalogItems); void FixBasketItemPictureUri(IEnumerable basketItems); void FixCampaignItemPictureUri(IEnumerable campaignItems); } ================================================ FILE: src/ClientApp/Services/Identity/AuthorizeRequest.cs ================================================ using System.Net; namespace eShop.ClientApp.Services.Identity; public class AuthorizeRequest { private readonly Uri _authorizeEndpoint; public AuthorizeRequest(string authorizeEndpoint) { _authorizeEndpoint = new Uri(authorizeEndpoint); } public string Create(IDictionary values) { var queryString = string.Join("&", values.Select(kvp => string.Format("{0}={1}", WebUtility.UrlEncode(kvp.Key), WebUtility.UrlEncode(kvp.Value))).ToArray()); return string.Format("{0}?{1}", _authorizeEndpoint.AbsoluteUri, queryString); } } ================================================ FILE: src/ClientApp/Services/Identity/IIdentityService.cs ================================================ using eShop.ClientApp.Models.User; namespace eShop.ClientApp.Services.Identity; public interface IIdentityService { Task SignInAsync(); Task SignOutAsync(); Task GetUserInfoAsync(); Task GetAuthTokenAsync(); } ================================================ FILE: src/ClientApp/Services/Identity/IdentityMockService.cs ================================================ using eShop.ClientApp.Models.User; namespace eShop.ClientApp.Services.Identity; public class IdentityMockService : IIdentityService { private bool _signedIn; public Task SignInAsync() { _signedIn = true; return Task.FromResult(_signedIn); } public Task SignOutAsync() { _signedIn = false; return Task.FromResult(_signedIn); } public Task GetUserInfoAsync() { if (!_signedIn) { return Task.FromResult(UserInfo.Default); } return Task.FromResult(new UserInfo { UserId = Guid.NewGuid().ToString(), PreferredUsername = "sampleUser", Name = "Sample", LastName = "User", CardNumber = "XXXXXXXXXXXX3456", CardHolder = "Sample User", CardSecurityNumber = "123", Address = "123 Sample Street", Country = "USA", State = "Washington", Street = "123 Sample Street", ZipCode = "12345", Email = "sample.user@example.com", EmailVerified = true, PhoneNumber = "1234567890", PhoneNumberVerified = true }); } public Task GetAuthTokenAsync() { if (!_signedIn) { return Task.FromResult(string.Empty); } return Task.FromResult(Guid.NewGuid().ToString()); } } ================================================ FILE: src/ClientApp/Services/Identity/IdentityService.cs ================================================ using eShop.ClientApp.Models.Token; using eShop.ClientApp.Models.User; using eShop.ClientApp.Services.Settings; using IdentityModel.OidcClient; using IBrowser = IdentityModel.OidcClient.Browser.IBrowser; namespace eShop.ClientApp.Services.Identity; public class IdentityService : IIdentityService { private readonly IBrowser _browser; private readonly ISettingsService _settingsService; private readonly HttpMessageHandler _httpMessageHandler; public IdentityService(IBrowser browser, ISettingsService settingsService, HttpMessageHandler httpMessageHandler) { _browser = browser; _settingsService = settingsService; _httpMessageHandler = httpMessageHandler; } public async Task SignInAsync() { var response = await GetClient().LoginAsync(new LoginRequest()).ConfigureAwait(false); if (response.IsError) { return false; } await _settingsService .SetUserTokenAsync( new UserToken { AccessToken = response.AccessToken, IdToken = response.IdentityToken, RefreshToken = response.RefreshToken, ExpiresAt = response.AccessTokenExpiration }) .ConfigureAwait(false); return !response.IsError; } public async Task SignOutAsync() { var response = await GetClient().LogoutAsync(new LogoutRequest()).ConfigureAwait(false); if (response.IsError) { return false; } await _settingsService.SetUserTokenAsync(default); return !response.IsError; } public async Task GetUserInfoAsync() { var authToken = await GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return UserInfo.Default; } var userInfoWithClaims = await GetClient().GetUserInfoAsync(authToken).ConfigureAwait(false); return new UserInfo { UserId = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "sub")?.Value, Email = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "email")?.Value, PhoneNumber = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "phone_number")?.Value, Street = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "address_street")?.Value, Address = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "address_city")?.Value, State = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "address_state")?.Value, ZipCode = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "address_zip_code")?.Value, Country = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "address_country")?.Value, PreferredUsername = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value, Name = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "name")?.Value, LastName = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "last_name")?.Value, CardNumber = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "card_number")?.Value, CardHolder = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "card_holder")?.Value, CardSecurityNumber = userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "card_security_number")?.Value, PhoneNumberVerified = bool.Parse(userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "phone_number_verified") ?.Value ?? "false"), EmailVerified = bool.Parse(userInfoWithClaims.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value ?? "false") }; } public async Task GetAuthTokenAsync() { var userToken = await _settingsService.GetUserTokenAsync().ConfigureAwait(false); if (userToken is null) { return string.Empty; } if (userToken.ExpiresAt.Subtract(DateTimeOffset.Now).TotalMinutes > 5) { return userToken.AccessToken; } var response = await GetClient().RefreshTokenAsync(userToken.RefreshToken).ConfigureAwait(false); if (response.IsError) { return string.Empty; } await _settingsService .SetUserTokenAsync( new UserToken { AccessToken = response.AccessToken, IdToken = response.IdentityToken, RefreshToken = response.RefreshToken, ExpiresAt = response.AccessTokenExpiration }) .ConfigureAwait(false); return response.AccessToken; } private OidcClient GetClient() { var options = new OidcClientOptions { Authority = _settingsService.IdentityEndpointBase, ClientId = _settingsService.ClientId, ClientSecret = _settingsService.ClientSecret, Scope = "openid profile basket orders offline_access", RedirectUri = _settingsService.CallbackUri, PostLogoutRedirectUri = _settingsService.CallbackUri, Browser = _browser, }; if (_httpMessageHandler is not null) { options.BackchannelHandler = _httpMessageHandler; } return new OidcClient(options); } } ================================================ FILE: src/ClientApp/Services/Location/ILocationService.cs ================================================ namespace eShop.ClientApp.Services.Location; public interface ILocationService { Task UpdateUserLocation(Models.Location.Location newLocReq); } ================================================ FILE: src/ClientApp/Services/Location/LocationService.cs ================================================ using eShop.ClientApp.Services.Identity; using eShop.ClientApp.Services.RequestProvider; using eShop.ClientApp.Services.Settings; namespace eShop.ClientApp.Services.Location; public class LocationService : ILocationService { private const string ApiUrlBase = "l/api/v1/locations"; private readonly IIdentityService _identityService; public LocationService(IIdentityService identityService) { _identityService = identityService; } public async Task UpdateUserLocation(Models.Location.Location newLocReq) { var accessToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(accessToken)) { return; } //TODO: Determine mapped location await Task.Delay(10).ConfigureAwait(false); //await _requestProvider.PostAsync(uri, newLocReq, token).ConfigureAwait(false); } } ================================================ FILE: src/ClientApp/Services/Navigation/INavigationService.cs ================================================ namespace eShop.ClientApp.Services; public interface INavigationService { Task InitializeAsync(); Task NavigateToAsync(string route, IDictionary routeParameters = null); Task PopAsync(); } ================================================ FILE: src/ClientApp/Services/Navigation/MauiNavigationService.cs ================================================ using eShop.ClientApp.Models.User; using eShop.ClientApp.Services.AppEnvironment; namespace eShop.ClientApp.Services; public class MauiNavigationService : INavigationService { private readonly IAppEnvironmentService _appEnvironmentService; public MauiNavigationService(IAppEnvironmentService appEnvironmentService) { _appEnvironmentService = appEnvironmentService; } public async Task InitializeAsync() { var user = await _appEnvironmentService.IdentityService.GetUserInfoAsync(); await NavigateToAsync(user == UserInfo.Default ? "//Login" : "//Main/Catalog"); } public Task NavigateToAsync(string route, IDictionary routeParameters = null) { var shellNavigation = new ShellNavigationState(route); return routeParameters != null ? Shell.Current.GoToAsync(shellNavigation, routeParameters) : Shell.Current.GoToAsync(shellNavigation); } public Task PopAsync() { return Shell.Current.GoToAsync(".."); } } ================================================ FILE: src/ClientApp/Services/OpenUrl/IOpenUrlService.cs ================================================ namespace eShop.ClientApp.Services.OpenUrl; public interface IOpenUrlService { Task OpenUrl(string url); } ================================================ FILE: src/ClientApp/Services/OpenUrl/OpenUrlService.cs ================================================ namespace eShop.ClientApp.Services.OpenUrl; public class OpenUrlService : IOpenUrlService { public async Task OpenUrl(string url) { if (await Launcher.CanOpenAsync(url)) { await Launcher.OpenAsync(url); } } } ================================================ FILE: src/ClientApp/Services/Order/IOrderService.cs ================================================ using eShop.ClientApp.Models.Basket; namespace eShop.ClientApp.Services.Order; public interface IOrderService { Task CreateOrderAsync(Models.Orders.Order newOrder); Task> GetOrdersAsync(); Task GetOrderAsync(int orderId); Task CancelOrderAsync(int orderId); OrderCheckout MapOrderToBasket(Models.Orders.Order order); } ================================================ FILE: src/ClientApp/Services/Order/OrderMockService.cs ================================================ using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Models.User; namespace eShop.ClientApp.Services.Order; public class OrderMockService : IOrderService { private static readonly DateTime MockExpirationDate = DateTime.Now.AddYears(5); private static readonly Address MockAdress = new() { Id = Guid.NewGuid(), City = "Seattle, WA", Street = "120 E 87th Street", CountryCode = "98122", Country = "United States", Latitude = 40.785091, Longitude = -73.968285, State = "Seattle", StateCode = "WA", ZipCode = "98101" }; private static readonly PaymentInfo MockPaymentInfo = new() { Id = Guid.NewGuid(), CardHolderName = "American Express", CardNumber = "XXXXXXXXXXXX0005", CardType = new CardType { Id = 3, Name = "MasterCard" }, Expiration = MockExpirationDate.ToString(), ExpirationMonth = MockExpirationDate.Month, ExpirationYear = MockExpirationDate.Year, SecurityNumber = "123" }; private static readonly List MockOrderItems = new() { new OrderItem { OrderId = Guid.NewGuid(), ProductId = Common.Common.MockCatalogItemId01, Discount = 15, ProductName = ".NET Bot Blue Sweatshirt (M)", Quantity = 1, UnitPrice = 16.50M, PictureUrl = "fake_product_01.png" }, new OrderItem { OrderId = Guid.NewGuid(), ProductId = Common.Common.MockCatalogItemId03, Discount = 0, ProductName = ".NET Bot Black Sweatshirt (M)", Quantity = 2, UnitPrice = 19.95M, PictureUrl = "fake_product_03.png" } }; private static readonly OrderCheckout MockOrderCheckout = new() { CardExpiration = DateTime.UtcNow, CardHolderName = "FakeCardHolderName", CardNumber = "XXXXXXXXXXXX3224", CardSecurityNumber = "1234", CardTypeId = 1, City = "FakeCity", Country = "FakeCountry", ZipCode = "FakeZipCode", Street = "FakeStreet" }; private readonly List MockOrders = new() { new Models.Orders.Order { OrderNumber = 1, SequenceNumber = 123, OrderDate = DateTime.Now, OrderStatus = "Submitted", OrderItems = MockOrderItems, CardTypeId = MockPaymentInfo.CardType.Id, CardHolderName = MockPaymentInfo.CardHolderName, CardNumber = MockPaymentInfo.CardNumber, CardSecurityNumber = MockPaymentInfo.SecurityNumber, CardExpiration = new DateTime(MockPaymentInfo.ExpirationYear, MockPaymentInfo.ExpirationMonth, 1), ShippingCity = MockAdress.City, ShippingState = MockAdress.State, ShippingCountry = MockAdress.Country, ShippingStreet = MockAdress.Street, Total = 36.46M }, new Models.Orders.Order { OrderNumber = 2, SequenceNumber = 132, OrderDate = DateTime.Now, OrderStatus = "Paid", OrderItems = MockOrderItems, CardTypeId = MockPaymentInfo.CardType.Id, CardHolderName = MockPaymentInfo.CardHolderName, CardNumber = MockPaymentInfo.CardNumber, CardSecurityNumber = MockPaymentInfo.SecurityNumber, CardExpiration = new DateTime(MockPaymentInfo.ExpirationYear, MockPaymentInfo.ExpirationMonth, 1), ShippingCity = MockAdress.City, ShippingState = MockAdress.State, ShippingCountry = MockAdress.Country, ShippingStreet = MockAdress.Street, Total = 36.46M }, new Models.Orders.Order { OrderNumber = 3, SequenceNumber = 231, OrderDate = DateTime.Now, OrderStatus = "Cancelled", OrderItems = MockOrderItems, CardTypeId = MockPaymentInfo.CardType.Id, CardHolderName = MockPaymentInfo.CardHolderName, CardNumber = MockPaymentInfo.CardNumber, CardSecurityNumber = MockPaymentInfo.SecurityNumber, CardExpiration = new DateTime(MockPaymentInfo.ExpirationYear, MockPaymentInfo.ExpirationMonth, 1), ShippingCity = MockAdress.City, ShippingState = MockAdress.State, ShippingCountry = MockAdress.Country, ShippingStreet = MockAdress.Street, Total = 36.46M }, new Models.Orders.Order { OrderNumber = 4, SequenceNumber = 131, OrderDate = DateTime.Now, OrderStatus = "Shipped", OrderItems = MockOrderItems, CardTypeId = MockPaymentInfo.CardType.Id, CardHolderName = MockPaymentInfo.CardHolderName, CardNumber = MockPaymentInfo.CardNumber, CardSecurityNumber = MockPaymentInfo.SecurityNumber, CardExpiration = new DateTime(MockPaymentInfo.ExpirationYear, MockPaymentInfo.ExpirationMonth, 1), ShippingCity = MockAdress.City, ShippingState = MockAdress.State, ShippingCountry = MockAdress.Country, ShippingStreet = MockAdress.Street, Total = 36.46M } }; public async Task> GetOrdersAsync() { await Task.Delay(10); return MockOrders .OrderByDescending(o => o.OrderNumber) .ToArray(); } public async Task GetOrderAsync(int orderId) { await Task.Delay(10); return MockOrders .FirstOrDefault(o => o.OrderNumber.Equals(orderId)); } public async Task CreateOrderAsync(Models.Orders.Order newOrder) { await Task.Delay(10); MockOrders.Add(newOrder); } public OrderCheckout MapOrderToBasket(Models.Orders.Order order) { return MockOrderCheckout; } public Task CancelOrderAsync(int orderId) { return Task.FromResult(true); } } ================================================ FILE: src/ClientApp/Services/Order/OrderService.cs ================================================ using System.Net; using eShop.ClientApp.Helpers; using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Services.Identity; using eShop.ClientApp.Services.RequestProvider; using eShop.ClientApp.Services.Settings; namespace eShop.ClientApp.Services.Order; public class OrderService : IOrderService { private const string ApiUrlBase = "api/orders"; private const string ApiVersion = "api-version=1.0"; private readonly IIdentityService _identityService; private readonly IRequestProvider _requestProvider; private readonly ISettingsService _settingsService; public OrderService(IIdentityService identityService, ISettingsService settingsService, IRequestProvider requestProvider) { _identityService = identityService; _settingsService = settingsService; _requestProvider = requestProvider; } public async Task CreateOrderAsync(Models.Orders.Order newOrder) { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return; } var uri = $"{UriHelper.CombineUri(_settingsService.GatewayOrdersEndpointBase, ApiUrlBase)}?{ApiVersion}"; var success = await _requestProvider.PostAsync(uri, newOrder, authToken, "x-requestid").ConfigureAwait(false); } public async Task> GetOrdersAsync() { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return Enumerable.Empty(); } var uri = $"{UriHelper.CombineUri(_settingsService.GatewayOrdersEndpointBase, ApiUrlBase)}?{ApiVersion}"; var orders = await _requestProvider.GetAsync>(uri, authToken).ConfigureAwait(false); return orders ?? Enumerable.Empty(); } public async Task GetOrderAsync(int orderId) { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return new Models.Orders.Order(); } try { var uri = $"{UriHelper.CombineUri(_settingsService.GatewayOrdersEndpointBase, $"{ApiUrlBase}/{orderId}")}?{ApiVersion}"; var order = await _requestProvider.GetAsync(uri, authToken).ConfigureAwait(false); return order; } catch { return new Models.Orders.Order(); } } public async Task CancelOrderAsync(int orderId) { var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(authToken)) { return false; } var uri = $"{UriHelper.CombineUri(_settingsService.GatewayOrdersEndpointBase, $"{ApiUrlBase}/cancel")}?{ApiVersion}"; var cancelOrderCommand = new CancelOrderCommand(orderId); var header = "x-requestid"; try { await _requestProvider.PutAsync(uri, cancelOrderCommand, authToken, header).ConfigureAwait(false); } //If the status of the order has changed before to click cancel button, we will get //a BadRequest HttpStatus catch (HttpRequestExceptionEx ex) when (ex.HttpCode == HttpStatusCode.BadRequest) { return false; } return true; } public OrderCheckout MapOrderToBasket(Models.Orders.Order order) { return new OrderCheckout { CardExpiration = order.CardExpiration, CardHolderName = order.CardHolderName, CardNumber = order.CardNumber, CardSecurityNumber = order.CardSecurityNumber, CardTypeId = order.CardTypeId, City = order.ShippingCity, State = order.ShippingState, Country = order.ShippingCountry, ZipCode = order.ShippingZipCode, Street = order.ShippingStreet }; } } ================================================ FILE: src/ClientApp/Services/RequestProvider/HttpRequestExceptionEx.cs ================================================ using System.Net; namespace eShop.ClientApp.Services.RequestProvider; public class HttpRequestExceptionEx : HttpRequestException { public HttpRequestExceptionEx(HttpStatusCode code) : this(code, null, null) { } public HttpRequestExceptionEx(HttpStatusCode code, string message) : this(code, message, null) { } public HttpRequestExceptionEx(HttpStatusCode code, string message, Exception inner) : base(message, inner) { HttpCode = code; } public HttpStatusCode HttpCode { get; } } ================================================ FILE: src/ClientApp/Services/RequestProvider/IRequestProvider.cs ================================================ namespace eShop.ClientApp.Services.RequestProvider; public interface IRequestProvider { Task GetAsync(string uri, string token = ""); Task PostAsync(string uri, TRequest data, string token = "", string header = ""); Task PostAsync(string uri, TRequest data, string token = "", string header = ""); Task PostAsync(string uri, string data, string clientId, string clientSecret); Task PutAsync(string uri, TResult data, string token = "", string header = ""); Task DeleteAsync(string uri, string token = ""); } ================================================ FILE: src/ClientApp/Services/RequestProvider/RequestProvider.cs ================================================ #nullable enable using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using eShop.ClientApp.Exceptions; namespace eShop.ClientApp.Services.RequestProvider; public class RequestProvider(HttpMessageHandler _messageHandler) : IRequestProvider { private readonly Lazy _httpClient = new(() => { var httpClient = _messageHandler is not null ? new HttpClient(_messageHandler) : new HttpClient(); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return httpClient; }, LazyThreadSafetyMode.ExecutionAndPublication); public async Task GetAsync(string uri, string token = "") { var httpClient = GetOrCreateHttpClient(token); using var response = await httpClient.GetAsync(uri).ConfigureAwait(false); await HandleResponse(response).ConfigureAwait(false); var result = await ReadFromJsonAsync(response.Content).ConfigureAwait(false); return result; } public async Task PostAsync(string uri, TRequest data, string token = "", string header = "") { var httpClient = GetOrCreateHttpClient(token); if (!string.IsNullOrEmpty(header)) { AddHeaderParameter(httpClient, header); } var requestContent = SerializeToJson(data); using HttpResponseMessage response = await httpClient.PostAsync(uri, requestContent).ConfigureAwait(false); await HandleResponse(response).ConfigureAwait(false); var result = await ReadFromJsonAsync(response.Content).ConfigureAwait(false); return result; } public async Task PostAsync(string uri, TRequest data, string token = "", string header = "") { var httpClient = GetOrCreateHttpClient(token); if (!string.IsNullOrEmpty(header)) { AddHeaderParameter(httpClient, header); } var requestContent = SerializeToJson(data); using var response = await httpClient.PostAsync(uri, requestContent).ConfigureAwait(false); await HandleResponse(response).ConfigureAwait(false); return response.IsSuccessStatusCode; } public async Task PostAsync(string uri, string data, string clientId, string clientSecret) { var httpClient = GetOrCreateHttpClient(string.Empty); if (!string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret)) { AddBasicAuthenticationHeader(httpClient, clientId, clientSecret); } using var content = new StringContent(data); content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); using var response = await httpClient.PostAsync(uri, content).ConfigureAwait(false); await HandleResponse(response).ConfigureAwait(false); var result = await ReadFromJsonAsync(response.Content).ConfigureAwait(false); return result; } public async Task PutAsync(string uri, TResult data, string token = "", string header = "") { var httpClient = GetOrCreateHttpClient(token); if (!string.IsNullOrEmpty(header)) { AddHeaderParameter(httpClient, header); } var requestContent = SerializeToJson(data); using HttpResponseMessage response = await httpClient.PutAsync(uri, requestContent).ConfigureAwait(false); await HandleResponse(response).ConfigureAwait(false); var result = await ReadFromJsonAsync(response.Content).ConfigureAwait(false); return result; } public async Task DeleteAsync(string uri, string token = "") { var httpClient = GetOrCreateHttpClient(token); await httpClient.DeleteAsync(uri).ConfigureAwait(false); } private HttpClient GetOrCreateHttpClient(string token = "") { var httpClient = _httpClient.Value; httpClient.DefaultRequestHeaders.Authorization = !string.IsNullOrEmpty(token) ? new AuthenticationHeaderValue("Bearer", token) : null; return httpClient; } private static void AddHeaderParameter(HttpClient httpClient, string parameter) { if (httpClient == null) { return; } if (string.IsNullOrEmpty(parameter)) { return; } httpClient.DefaultRequestHeaders.Add(parameter, Guid.NewGuid().ToString()); } private static void AddBasicAuthenticationHeader(HttpClient httpClient, string clientId, string clientSecret) { if (httpClient == null) { return; } if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) { return; } httpClient.DefaultRequestHeaders.Authorization = new BasicAuthenticationHeaderValue(clientId, clientSecret); } private static async Task HandleResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Unauthorized) { throw new ServiceAuthenticationException(content); } throw new HttpRequestExceptionEx(response.StatusCode, content); } } private static async Task ReadFromJsonAsync(HttpContent content) { using var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); var data = await JsonSerializer.DeserializeAsync(contentStream, typeof(T), EShopJsonSerializerContext.Default).ConfigureAwait(false); return (T?)data; } private static JsonContent SerializeToJson(T data) { var typeInfo = EShopJsonSerializerContext.Default.GetTypeInfo(typeof(T)) ?? throw new InvalidOperationException($"Missing type info for {typeof(T)}"); return JsonContent.Create(data, typeInfo); } } ================================================ FILE: src/ClientApp/Services/Settings/ISettingsService.cs ================================================ #nullable enable using eShop.ClientApp.Models.Token; namespace eShop.ClientApp.Services.Settings; public interface ISettingsService { bool UseMocks { get; set; } string DefaultEndpoint { get; set; } string RegistrationEndpoint { get; set; } string ClientId { get; set; } string ClientSecret { get; set; } string CallbackUri { get; set; } string IdentityEndpointBase { get; set; } string GatewayCatalogEndpointBase { get; set; } string GatewayOrdersEndpointBase { get; set; } string GatewayBasketEndpointBase { get; set; } bool UseFakeLocation { get; set; } string Latitude { get; set; } string Longitude { get; set; } bool AllowGpsLocation { get; set; } Task GetUserTokenAsync(); Task SetUserTokenAsync(UserToken? userToken); } ================================================ FILE: src/ClientApp/Services/Settings/SettingsService.cs ================================================ using System.Globalization; using System.Text.Json; using eShop.ClientApp.Models.Token; namespace eShop.ClientApp.Services.Settings; public class SettingsService : ISettingsService { #region Setting Constants private const string UserAccessToken = "user_token"; private const string IdUseMocks = "use_mocks"; private const string IdIdentityBase = "url_base"; private const string DefaultClientId = "maui"; private const string DefaultClientSecret = "secret"; private const string DefaultCallbackUri = "maui://authcallback"; private const string IdGatewayMarketingBase = "url_marketing"; private const string IdGatewayShoppingBase = "url_shopping"; private const string IdGatewayOrdersBase = "url_orders"; private const string IdGatewayBasketBase = "url_basket"; private const string IdUseFakeLocation = "use_fake_location"; private const string IdLatitude = "latitude"; private const string IdLongitude = "longitude"; private const string IdAllowGpsLocation = "allow_gps_location"; private readonly bool UseMocksDefault = true; private readonly bool UseFakeLocationDefault = false; private readonly bool AllowGpsLocationDefault = false; private readonly double FakeLatitudeDefault = 47.604610d; private readonly double FakeLongitudeDefault = -122.315752d; #endregion #region Settings Properties public async Task SetUserTokenAsync(UserToken userToken) { await SecureStorage .SetAsync(UserAccessToken, userToken is not null ? JsonSerializer.Serialize(userToken, EShopJsonSerializerContext.Default.UserToken) : string.Empty) .ConfigureAwait(false); } public async Task GetUserTokenAsync() { var userToken = await SecureStorage.GetAsync(UserAccessToken).ConfigureAwait(false); return string.IsNullOrEmpty(userToken) ? default : JsonSerializer.Deserialize(userToken, EShopJsonSerializerContext.Default.UserToken); } public bool UseMocks { get => Preferences.Get(IdUseMocks, UseMocksDefault); set => Preferences.Set(IdUseMocks, value); } public string DefaultEndpoint { get => Preferences.Get(nameof(DefaultEndpoint), string.Empty); set => Preferences.Set(nameof(DefaultEndpoint), value); } public string RegistrationEndpoint { get => Preferences.Get(nameof(RegistrationEndpoint), string.Empty); set => Preferences.Set(nameof(RegistrationEndpoint), value); } public string AuthorizeEndpoint { get => Preferences.Get(nameof(AuthorizeEndpoint), string.Empty); set => Preferences.Set(nameof(AuthorizeEndpoint), value); } public string UserInfoEndpoint { get => Preferences.Get(nameof(UserInfoEndpoint), string.Empty); set => Preferences.Set(nameof(UserInfoEndpoint), value); } public string ClientId { get => Preferences.Get(nameof(ClientId), DefaultClientId); set => Preferences.Set(nameof(ClientId), value); } public string ClientSecret { get => Preferences.Get(nameof(ClientSecret), DefaultClientSecret); set => Preferences.Set(nameof(ClientSecret), value); } public string CallbackUri { get => Preferences.Get(nameof(CallbackUri), DefaultCallbackUri); set => Preferences.Set(nameof(CallbackUri), value); } public string IdentityEndpointBase { get => Preferences.Get(IdIdentityBase, string.Empty); set => Preferences.Set(IdIdentityBase, value); } public string GatewayCatalogEndpointBase { get => Preferences.Get(IdGatewayShoppingBase, string.Empty); set => Preferences.Set(IdGatewayShoppingBase, value); } public string GatewayMarketingEndpointBase { get => Preferences.Get(IdGatewayMarketingBase, string.Empty); set => Preferences.Set(IdGatewayMarketingBase, value); } public string GatewayOrdersEndpointBase { get => Preferences.Get(IdGatewayOrdersBase, string.Empty); set => Preferences.Set(IdGatewayOrdersBase, value); } public string GatewayBasketEndpointBase { get => Preferences.Get(IdGatewayBasketBase, string.Empty); set => Preferences.Set(IdGatewayBasketBase, value); } public bool UseFakeLocation { get => Preferences.Get(IdUseFakeLocation, UseFakeLocationDefault); set => Preferences.Set(IdUseFakeLocation, value); } public string Latitude { get => Preferences.Get(IdLatitude, FakeLatitudeDefault.ToString(CultureInfo.InvariantCulture)); set => Preferences.Set(IdLatitude, value); } public string Longitude { get => Preferences.Get(IdLongitude, FakeLongitudeDefault.ToString(CultureInfo.InvariantCulture)); set => Preferences.Set(IdLongitude, value); } public bool AllowGpsLocation { get => Preferences.Get(IdAllowGpsLocation, AllowGpsLocationDefault); set => Preferences.Set(IdAllowGpsLocation, value); } #endregion } ================================================ FILE: src/ClientApp/Services/Theme/ITheme.cs ================================================ namespace eShop.ClientApp.Services.Theme; public interface ITheme { void SetStatusBarColor(Color color, bool darkStatusBarTint); } ================================================ FILE: src/ClientApp/Services/Theme/Theme.shared.cs ================================================ namespace eShop.ClientApp.Services.Theme; public class Theme : ITheme { public void SetStatusBarColor(Color color, bool darkStatusBarTint) { } } ================================================ FILE: src/ClientApp/Triggers/BeginAnimation.cs ================================================ using eShop.ClientApp.Animations.Base; namespace eShop.ClientApp.Triggers; public class BeginAnimation : TriggerAction { public AnimationBase Animation { get; set; } protected override async void Invoke(VisualElement sender) { if (Animation != null) { await Animation.Begin(); } } } ================================================ FILE: src/ClientApp/Validations/IValidationRule.cs ================================================ namespace eShop.ClientApp.Validations; public interface IValidationRule { string ValidationMessage { get; set; } bool Check(T value); } ================================================ FILE: src/ClientApp/Validations/IValidity.cs ================================================ namespace eShop.ClientApp.Validations; public interface IValidity { bool IsValid { get; } } ================================================ FILE: src/ClientApp/Validations/IsNotNullOrEmptyRule.cs ================================================ namespace eShop.ClientApp.Validations; public class IsNotNullOrEmptyRule : IValidationRule { public string ValidationMessage { get; set; } public bool Check(T value) { return value is string str && !string.IsNullOrWhiteSpace(str); } } ================================================ FILE: src/ClientApp/Validations/ValidatableObject.cs ================================================ namespace eShop.ClientApp.Validations; public class ValidatableObject : ObservableObject, IValidity { private IEnumerable _errors; private bool _isValid; private T _value; public ValidatableObject() { _isValid = true; _errors = Enumerable.Empty(); } public List> Validations { get; } = new(); public IEnumerable Errors { get => _errors; private set => SetProperty(ref _errors, value); } public T Value { get => _value; set => SetProperty(ref _value, value); } public bool IsValid { get => _isValid; private set => SetProperty(ref _isValid, value); } public bool Validate() { Errors = Validations ?.Where(v => !v.Check(Value)) ?.Select(v => v.ValidationMessage) ?.ToArray() ?? Enumerable.Empty(); IsValid = !Errors.Any(); return IsValid; } } ================================================ FILE: src/ClientApp/ViewModels/Base/IViewModelBase.cs ================================================ using eShop.ClientApp.Services; namespace eShop.ClientApp.ViewModels.Base; public interface IViewModelBase : IQueryAttributable { public INavigationService NavigationService { get; } public IAsyncRelayCommand InitializeAsyncCommand { get; } public bool IsBusy { get; } public bool IsInitialized { get; } Task InitializeAsync(); } ================================================ FILE: src/ClientApp/ViewModels/Base/ViewModelBase.cs ================================================ using eShop.ClientApp.Services; namespace eShop.ClientApp.ViewModels.Base; public abstract partial class ViewModelBase : ObservableObject, IViewModelBase { private long _isBusy; [ObservableProperty] private bool _isInitialized; public ViewModelBase(INavigationService navigationService) { NavigationService = navigationService; InitializeAsyncCommand = new AsyncRelayCommand( async () => { await IsBusyFor(InitializeAsync); IsInitialized = true; }, AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); } public bool IsBusy => Interlocked.Read(ref _isBusy) > 0; public INavigationService NavigationService { get; } public IAsyncRelayCommand InitializeAsyncCommand { get; } public virtual void ApplyQueryAttributes(IDictionary query) { } public virtual Task InitializeAsync() { return Task.CompletedTask; } protected async Task IsBusyFor(Func unitOfWork) { Interlocked.Increment(ref _isBusy); OnPropertyChanged(nameof(IsBusy)); try { await unitOfWork(); } finally { Interlocked.Decrement(ref _isBusy); OnPropertyChanged(nameof(IsBusy)); } } } ================================================ FILE: src/ClientApp/ViewModels/BasketViewModel.cs ================================================ using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class BasketViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; private readonly ObservableCollectionEx _basketItems = new(); private readonly ISettingsService _settingsService; public BasketViewModel( IAppEnvironmentService appEnvironmentService, INavigationService navigationService, ISettingsService settingsService) : base(navigationService) { _appEnvironmentService = appEnvironmentService; _settingsService = settingsService; } public int BadgeCount => _basketItems?.Sum(basketItem => basketItem.Quantity) ?? 0; public decimal Total => _basketItems?.Sum(basketItem => basketItem.Quantity * basketItem.UnitPrice) ?? 0m; public IReadOnlyList BasketItems => _basketItems; public override async Task InitializeAsync() { // Update Basket var basket = await _appEnvironmentService.BasketService.GetBasketAsync(); if ((basket?.Items?.Count ?? 0) > 0) { await _basketItems.ReloadDataAsync( async innerList => { foreach (var basketItem in basket.Items.ToArray()) { var catalogItem = await _appEnvironmentService.CatalogService.GetCatalogItemAsync(basketItem.ProductId); basketItem.PictureUrl = catalogItem.PictureUri; basketItem.ProductName = catalogItem.Name; basketItem.UnitPrice = catalogItem.Price; await AddBasketItemAsync(basketItem, innerList); } }); } } [RelayCommand] private Task AddAsync(BasketItem item) { return AddBasketItemAsync(item, _basketItems); } private async Task AddBasketItemAsync(BasketItem item, IList basketItems) { basketItems.Add(item); var basket = await _appEnvironmentService.BasketService.GetBasketAsync(); if (basket != null) { basket.AddItemToBasket(item); await _appEnvironmentService.BasketService.UpdateBasketAsync(basket); } ReCalculateTotal(); } [RelayCommand] private async Task DeleteAsync(BasketItem item) { _basketItems.Remove(item); var basket = await _appEnvironmentService.BasketService.GetBasketAsync(); if (basket != null) { basket.RemoveItemFromBasket(item); await _appEnvironmentService.BasketService.UpdateBasketAsync(basket); } ReCalculateTotal(); } public async Task ClearBasketItems() { _basketItems.Clear(); await _appEnvironmentService.BasketService.ClearBasketAsync(); ReCalculateTotal(); } private void ReCalculateTotal() { OnPropertyChanged(nameof(BadgeCount)); OnPropertyChanged(nameof(Total)); } [RelayCommand] private async Task CheckoutAsync() { if (_basketItems?.Any() ?? false) { _appEnvironmentService.BasketService.LocalBasketItems = _basketItems; await NavigationService.NavigateToAsync("Checkout"); } } } ================================================ FILE: src/ClientApp/ViewModels/CatalogItemViewModel.cs ================================================ using CommunityToolkit.Mvvm.Messaging; using eShop.ClientApp.Messages; using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class CatalogItemViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; [ObservableProperty] private CatalogItem _catalogItem; public CatalogItemViewModel(IAppEnvironmentService appEnvironmentService, INavigationService navigationService) : base(navigationService) { _appEnvironmentService = appEnvironmentService; } public override void ApplyQueryAttributes(IDictionary query) { base.ApplyQueryAttributes(query); CatalogItem = query.ValueAs("CatalogItem"); } [RelayCommand] private async Task AddCatalogItemAsync() { if (CatalogItem is null) { return; } var basket = await _appEnvironmentService.BasketService.GetBasketAsync(); if (basket is not null) { basket.AddItemToBasket( new BasketItem { ProductId = CatalogItem.Id, ProductName = CatalogItem.Name, PictureUrl = CatalogItem.PictureUri, UnitPrice = CatalogItem.Price, Quantity = 1 }); var basketUpdate = await _appEnvironmentService.BasketService.UpdateBasketAsync(basket); WeakReferenceMessenger.Default .Send(new ProductCountChangedMessage(basketUpdate.ItemCount)); await NavigationService.PopAsync(); } } [RelayCommand] private async Task DismissAsync() { await NavigationService.PopAsync(); } } ================================================ FILE: src/ClientApp/ViewModels/CatalogViewModel.cs ================================================ #nullable enable using CommunityToolkit.Mvvm.Messaging; using eShop.ClientApp.Messages; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class CatalogViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; private readonly ObservableCollectionEx _brands = new(); private readonly ObservableCollectionEx _products = new(); private readonly ObservableCollectionEx _types = new(); [ObservableProperty] private int _badgeCount; private bool _initialized; [ObservableProperty] private bool _isFiltering; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanFilter))] [NotifyCanExecuteChangedFor(nameof(ApplyFilterCommand))] private CatalogBrand? _selectedBrand; [ObservableProperty] private CatalogItem? _selectedProduct; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanFilter))] [NotifyCanExecuteChangedFor(nameof(ApplyFilterCommand))] private CatalogType? _selectedType; public CatalogViewModel( IAppEnvironmentService appEnvironmentService, INavigationService navigationService) : base(navigationService) { _appEnvironmentService = appEnvironmentService; _products = new ObservableCollectionEx(); _brands = new ObservableCollectionEx(); _types = new ObservableCollectionEx(); WeakReferenceMessenger.Default .Register( this, (_, message) => { BadgeCount = message.Value; }); } public bool CanFilter => SelectedBrand is not null && SelectedType is not null; public IReadOnlyList Products => _products; public IReadOnlyList Brands => _brands; public IReadOnlyList Types => _types; public override async Task InitializeAsync() { if (_initialized) { return; } _initialized = true; await IsBusyFor( async () => { // Get Catalog, Brands and Types var products = await _appEnvironmentService.CatalogService.GetCatalogAsync(); var brands = await _appEnvironmentService.CatalogService.GetCatalogBrandAsync(); var types = await _appEnvironmentService.CatalogService.GetCatalogTypeAsync(); var basket = await _appEnvironmentService.BasketService.GetBasketAsync(); BadgeCount = basket.ItemCount; _products.ReloadData(products); _brands.ReloadData(brands.Select(x => new CatalogBrandSelectionViewModel {Value = x})); _types.ReloadData(types.Select(x => new CatalogTypeSelectionViewModel {Value = x})); }); } [RelayCommand] private async Task ViewCatalogItemAsync(CatalogItem catalogItem) { SelectedProduct = null; if (catalogItem is null) { return; } await NavigationService.NavigateToAsync( "ViewCatalogItem", new Dictionary {["CatalogItem"] = catalogItem}); } [RelayCommand] private void Filter() { IsFiltering = !IsFiltering; } [RelayCommand] public void SelectCatalogBrand(CatalogBrand? selectedItem) { foreach (var brand in Brands) { var isSelection = brand.Value == selectedItem; if (!isSelection) { brand.Selected = false; continue; } if (brand.Selected) { SelectedBrand = null; brand.Selected = false; continue; } SelectedBrand = selectedItem; brand.Selected = true; } } [RelayCommand] public void SelectCatalogType(CatalogType? selectedItem) { foreach (var type in Types) { var isSelection = type.Value == selectedItem; if (!isSelection) { type.Selected = false; continue; } if (type.Selected) { SelectedType = null; type.Selected = false; continue; } SelectedType = selectedItem; type.Selected = true; } } [RelayCommand] private async Task ApplyFilterAsync() { await IsBusyFor( async () => { if (SelectedBrand is not null && SelectedType is not null) { var filteredProducts = await _appEnvironmentService.CatalogService.FilterAsync(SelectedBrand.Id, SelectedType.Id); _products.ReloadData(filteredProducts); } IsFiltering = false; }); } [RelayCommand] private async Task ClearFilterAsync() { await IsBusyFor( async () => { SelectCatalogBrand(default); SelectCatalogType(default); var allProducts = await _appEnvironmentService.CatalogService.GetCatalogAsync(); _products.ReloadData(allProducts); IsFiltering = false; }); } [RelayCommand] private async Task ViewBasket() { await NavigationService.NavigateToAsync("Basket"); } } public class CatalogBrandSelectionViewModel : SelectionViewModel { } public class CatalogTypeSelectionViewModel : SelectionViewModel { } ================================================ FILE: src/ClientApp/ViewModels/CheckoutViewModel.cs ================================================ using CommunityToolkit.Mvvm.Messaging; using eShop.ClientApp.Messages; using eShop.ClientApp.Models.Basket; using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Models.User; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class CheckoutViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; private readonly BasketViewModel _basketViewModel; private readonly IDialogService _dialogService; private readonly ISettingsService _settingsService; [ObservableProperty] private Order _order; [ObservableProperty] private Address _shippingAddress; public CheckoutViewModel( BasketViewModel basketViewModel, IAppEnvironmentService appEnvironmentService, IDialogService dialogService, ISettingsService settingsService, INavigationService navigationService) : base(navigationService) { _dialogService = dialogService; _appEnvironmentService = appEnvironmentService; _settingsService = settingsService; _basketViewModel = basketViewModel; } public override async Task InitializeAsync() { await IsBusyFor( async () => { var basketItems = _appEnvironmentService.BasketService.LocalBasketItems; var userInfo = await _appEnvironmentService.IdentityService.GetUserInfoAsync(); // Create Shipping Address ShippingAddress = new Address { Id = !string.IsNullOrEmpty(userInfo?.UserId) ? new Guid(userInfo.UserId) : Guid.NewGuid(), Street = userInfo?.Street, ZipCode = userInfo?.ZipCode, State = userInfo?.State, Country = userInfo?.Country, City = userInfo?.Address }; // Create Payment Info var paymentInfo = new PaymentInfo { CardNumber = userInfo?.CardNumber, CardHolderName = userInfo?.CardHolder, CardType = new CardType {Id = 3, Name = "MasterCard"}, SecurityNumber = userInfo?.CardSecurityNumber }; var orderItems = CreateOrderItems(basketItems); // Create new Order Order = new Order { //TODO: Get a better order number generator OrderNumber = (int)DateTimeOffset.Now.TimeOfDay.TotalMilliseconds, UserId = userInfo.UserId, UserName = userInfo.PreferredUsername, OrderItems = orderItems, OrderStatus = "Submitted", OrderDate = DateTime.Now, CardHolderName = paymentInfo.CardHolderName, CardNumber = paymentInfo.CardNumber, CardSecurityNumber = paymentInfo.SecurityNumber, CardExpiration = DateTime.UtcNow.AddYears(5), CardTypeId = paymentInfo.CardType.Id, ShippingState = ShippingAddress.State, ShippingCountry = ShippingAddress.Country, ShippingStreet = ShippingAddress.Street, ShippingCity = ShippingAddress.City, ShippingZipCode = ShippingAddress.ZipCode, Total = CalculateTotal(orderItems) }; if (_settingsService.UseMocks) { // Get number of orders var orders = await _appEnvironmentService.OrderService.GetOrdersAsync(); // Create the OrderNumber Order.OrderNumber = orders.Count() + 1; OnPropertyChanged(nameof(Order)); } }); } [RelayCommand] private async Task CheckoutAsync() { try { var basket = _appEnvironmentService.OrderService.MapOrderToBasket(Order); basket.RequestId = Guid.NewGuid(); await _appEnvironmentService.OrderService.CreateOrderAsync(Order); // Clean Basket await _appEnvironmentService.BasketService.ClearBasketAsync(); // Reset Basket badge await _basketViewModel.ClearBasketItems(); WeakReferenceMessenger.Default .Send(new ProductCountChangedMessage(0)); // Navigate to Orders await NavigationService.NavigateToAsync("//Main/Catalog"); // Show Dialog await _dialogService.ShowAlertAsync("Order sent successfully!", "Checkout", "Ok"); } catch (Exception ex) { Console.WriteLine(ex); await _dialogService.ShowAlertAsync("An error ocurred. Please, try again.", "Oops!", "Ok"); } } private static List CreateOrderItems(IEnumerable basketItems) { var orderItems = new List(); foreach (var basketItem in basketItems) { if (!string.IsNullOrEmpty(basketItem.ProductName)) { orderItems.Add(new OrderItem { OrderId = null, ProductId = basketItem.ProductId, ProductName = basketItem.ProductName, PictureUrl = basketItem.PictureUrl, Quantity = basketItem.Quantity, UnitPrice = basketItem.UnitPrice }); } } return orderItems; } private static decimal CalculateTotal(List orderItems) { decimal total = 0; foreach (var orderItem in orderItems) { total += orderItem.Quantity * orderItem.UnitPrice; } return total; } } ================================================ FILE: src/ClientApp/ViewModels/LoginViewModel.cs ================================================ using System.Diagnostics; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.OpenUrl; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.Validations; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class LoginViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; private readonly IOpenUrlService _openUrlService; private readonly ISettingsService _settingsService; [ObservableProperty] private bool _isLogin; [ObservableProperty] private bool _isMock; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(MockSignInCommand))] private bool _isValid; [ObservableProperty] private string _loginUrl; [ObservableProperty] private ValidatableObject _password = new(); [ObservableProperty] private ValidatableObject _userName = new(); public LoginViewModel( IOpenUrlService openUrlService, IAppEnvironmentService appEnvironmentService, INavigationService navigationService, ISettingsService settingsService) : base(navigationService) { _settingsService = settingsService; _openUrlService = openUrlService; _appEnvironmentService = appEnvironmentService; InvalidateMock(); } public override async void ApplyQueryAttributes(IDictionary query) { base.ApplyQueryAttributes(query); if (query.ValueAsBool("Logout")) { await PerformLogoutAsync(); } } public override Task InitializeAsync() { return Task.CompletedTask; } [RelayCommand(CanExecute = nameof(IsValid))] private async Task MockSignInAsync() { await IsBusyFor( async () => { var isAuthenticated = false; try { await Task.Delay(1000); isAuthenticated = true; } catch (Exception ex) { Debug.WriteLine($"[SignIn] Error signing in: {ex}"); } if (isAuthenticated) { await NavigationService.NavigateToAsync("//Main/Catalog"); } }); } [RelayCommand] private async Task SignInAsync() { await IsBusyFor( async () => { var loginSuccess = await _appEnvironmentService.IdentityService.SignInAsync(); if (loginSuccess) { await NavigationService.NavigateToAsync("//Main/Catalog"); } }); } [RelayCommand] private Task RegisterAsync() { return _openUrlService.OpenUrl(_settingsService.RegistrationEndpoint); } [RelayCommand] private async Task PerformLogoutAsync() { await _appEnvironmentService.IdentityService.SignOutAsync(); _settingsService.UseFakeLocation = false; UserName.Value = string.Empty; Password.Value = string.Empty; } [RelayCommand] private Task SettingsAsync() { return NavigationService.NavigateToAsync("Settings"); } [RelayCommand] private void Validate() { IsValid = UserName.Validate() && Password.Validate(); } private void AddValidations() { UserName.Validations.Add(new IsNotNullOrEmptyRule {ValidationMessage = "A username is required."}); Password.Validations.Add(new IsNotNullOrEmptyRule {ValidationMessage = "A password is required."}); } public void InvalidateMock() { IsMock = false; //_settingsService.UseMocks; } } ================================================ FILE: src/ClientApp/ViewModels/MainViewModel.cs ================================================ using eShop.ClientApp.Services; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class MainViewModel : ViewModelBase { public MainViewModel(INavigationService navigationService) : base(navigationService) { } [RelayCommand] private async Task SettingsAsync() { await NavigationService.NavigateToAsync("Settings"); } } ================================================ FILE: src/ClientApp/ViewModels/MapViewModel.cs ================================================ using eShop.ClientApp.Services; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class MapViewModel : ViewModelBase { [ObservableProperty] private IEnumerable _stores; public MapViewModel(INavigationService navigationService) : base(navigationService) { } public override Task InitializeAsync() { Stores = new[] { new Store { Address = "Building 92, Redmond, WA", Description = "Microsoft Visitor Center", Location = new Location(47.6423109, -122.1368406) } }; return Task.CompletedTask; } } public record Store { public Location Location { get; set; } public string Address { get; set; } public string Description { get; set; } } ================================================ FILE: src/ClientApp/ViewModels/ObservableCollectionEx.cs ================================================ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; namespace eShop.ClientApp.ViewModels; public class ObservableCollectionEx : ObservableCollection { public ObservableCollectionEx() { } public ObservableCollectionEx(IEnumerable collection) : base(collection) { } public ObservableCollectionEx(List list) : base(list) { } public void ReloadData(IEnumerable items) { ReloadData( innerList => { foreach (var item in items) { innerList.Add(item); } }); } public void ReloadData(Action> innerListAction) { Items.Clear(); innerListAction(Items); OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); OnPropertyChanged(new PropertyChangedEventArgs("Items[]")); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } public async Task ReloadDataAsync(Func, Task> innerListAction) { Items.Clear(); await innerListAction(Items); OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); OnPropertyChanged(new PropertyChangedEventArgs("Items[]")); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } } ================================================ FILE: src/ClientApp/ViewModels/OrderDetailViewModel.cs ================================================ using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class OrderDetailViewModel : ViewModelBase, IQueryAttributable { private readonly IAppEnvironmentService _appEnvironmentService; private readonly ISettingsService _settingsService; [ObservableProperty] private bool _isSubmittedOrder; [ObservableProperty] private Order _order; [ObservableProperty] private int _orderNumber; [ObservableProperty] private string _orderStatusText; public OrderDetailViewModel( IAppEnvironmentService appEnvironmentService, INavigationService navigationService, ISettingsService settingsService) : base(navigationService) { _appEnvironmentService = appEnvironmentService; _settingsService = settingsService; } public override async Task InitializeAsync() { await IsBusyFor( async () => { // Get order detail info Order = await _appEnvironmentService.OrderService.GetOrderAsync(OrderNumber); IsSubmittedOrder = Order.OrderStatus.Equals("Submitted", StringComparison.OrdinalIgnoreCase); OrderStatusText = Order.OrderStatus; }); } [RelayCommand] private async Task ToggleCancelOrderAsync() { var result = await _appEnvironmentService.OrderService.CancelOrderAsync(Order.OrderNumber); if (result) { OrderStatusText = "Cancelled"; } else { Order = await _appEnvironmentService.OrderService.GetOrderAsync(Order.OrderNumber); OrderStatusText = Order.OrderStatus; } IsSubmittedOrder = false; } public override void ApplyQueryAttributes(IDictionary query) { if (query.TryGetValue("OrderNumber", out var orderNumber)) { if (orderNumber is string orderNumberString && int.TryParse(orderNumberString, out var parsedOrderNumber)) { OrderNumber = parsedOrderNumber; } else if (orderNumber is int intOrderNumber) { OrderNumber = intOrderNumber; } } } } ================================================ FILE: src/ClientApp/ViewModels/ProfileViewModel.cs ================================================ using eShop.ClientApp.Models.Orders; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.ViewModels.Base; namespace eShop.ClientApp.ViewModels; public partial class ProfileViewModel : ViewModelBase { private readonly IAppEnvironmentService _appEnvironmentService; private readonly ObservableCollectionEx _orders; private readonly ISettingsService _settingsService; [ObservableProperty] private Order _selectedOrder; public ProfileViewModel( IAppEnvironmentService appEnvironmentService, ISettingsService settingsService, INavigationService navigationService) : base(navigationService) { _appEnvironmentService = appEnvironmentService; _settingsService = settingsService; _orders = new ObservableCollectionEx(); } public IList Orders => _orders; public override async Task InitializeAsync() { await RefreshAsync(); } [RelayCommand] private async Task LogoutAsync() { await IsBusyFor( async () => { // Logout await NavigationService.NavigateToAsync( "//Login", new Dictionary {{"Logout", true}}); }); } [RelayCommand] private async Task RefreshAsync() { if (IsBusy) { return; } await IsBusyFor( async () => { // Get orders var orders = await _appEnvironmentService.OrderService.GetOrdersAsync(); _orders.ReloadData(orders); }); } [RelayCommand] private async Task OrderDetailAsync(Order order) { if (order is null || IsBusy) { return; } await NavigationService.NavigateToAsync( "OrderDetail", new Dictionary {{nameof(Order.OrderNumber), order.OrderNumber}}); } } ================================================ FILE: src/ClientApp/ViewModels/SelectionViewModel.cs ================================================ namespace eShop.ClientApp.ViewModels; public partial class SelectionViewModel : ObservableObject { [ObservableProperty] private bool _selected; [ObservableProperty] private T _value; } ================================================ FILE: src/ClientApp/ViewModels/SettingsViewModel.cs ================================================ using System.ComponentModel; using System.Globalization; using System.Windows.Input; using eShop.ClientApp.Services; using eShop.ClientApp.Services.AppEnvironment; using eShop.ClientApp.Services.Location; using eShop.ClientApp.Services.Settings; using eShop.ClientApp.ViewModels.Base; using Location = eShop.ClientApp.Models.Location.Location; namespace eShop.ClientApp.ViewModels; public class SettingsViewModel : ViewModelBase { //Needed if using Android Emulator Locally. See https://learn.microsoft.com/en-us/dotnet/maui/data-cloud/local-web-services?view=net-maui-8.0#android private static string _baseAddress = DeviceInfo.Platform == DevicePlatform.Android ? "10.0.2.2" : "localhost"; private readonly IAppEnvironmentService _appEnvironmentService; private readonly ILocationService _locationService; private readonly ISettingsService _settingsService; private bool _allowGpsLocation; private string _gatewayBasketEndpoint; private string _gatewayCatalogEndpoint; private string _gatewayOrdersEndpoint; private string _gpsWarningMessage; private string _identityEndpoint; private double _latitude; private double _longitude; private bool _useAzureServices; private bool _useFakeLocation; public SettingsViewModel( ILocationService locationService, IAppEnvironmentService appEnvironmentService, INavigationService navigationService, ISettingsService settingsService) : base(navigationService) { _settingsService = settingsService; _locationService = locationService; _appEnvironmentService = appEnvironmentService; _useAzureServices = !_settingsService.UseMocks; _identityEndpoint = _settingsService.IdentityEndpointBase; _latitude = double.Parse(_settingsService.Latitude, CultureInfo.CurrentCulture); _longitude = double.Parse(_settingsService.Longitude, CultureInfo.CurrentCulture); _useFakeLocation = _settingsService.UseFakeLocation; _allowGpsLocation = _settingsService.AllowGpsLocation; _gpsWarningMessage = string.Empty; IdentityEndpoint = !string.IsNullOrEmpty(_settingsService.IdentityEndpointBase) ? _settingsService.IdentityEndpointBase : $"https://{_baseAddress}:5243"; GatewayCatalogEndpoint = !string.IsNullOrEmpty(_settingsService.GatewayCatalogEndpointBase) ? _settingsService.GatewayCatalogEndpointBase : $"http://{_baseAddress}:11632"; GatewayBasketEndpoint = !string.IsNullOrEmpty(_settingsService.GatewayBasketEndpointBase) ? _settingsService.GatewayBasketEndpointBase : $"http://{_baseAddress}:5221"; GatewayOrdersEndpoint = !string.IsNullOrEmpty(_settingsService.GatewayOrdersEndpointBase) ? _settingsService.GatewayOrdersEndpointBase : $"http://{_baseAddress}:11632"; ToggleMockServicesCommand = new RelayCommand(ToggleMockServices); ToggleFakeLocationCommand = new RelayCommand(ToggleFakeLocation); ToggleSendLocationCommand = new AsyncRelayCommand(ToggleSendLocationAsync); ToggleAllowGpsLocationCommand = new RelayCommand(ToggleAllowGpsLocation); UseAzureServices = !_settingsService.UseMocks; } public string TitleUseAzureServices => "Use Microservices/Containers from eShop"; public string DescriptionUseAzureServices => !UseAzureServices ? "Currently using mock services that are simulated objects that mimic the behavior of real services using a controlled approach. Toggle on to configure the use of microserivces/containers." : "When enabling the use of microservices/containers, the app will attempt to use real services deployed as Docker/Kubernetes containers at the specified base endpoint, which will must be reachable through the network."; public bool UseAzureServices { get => _useAzureServices; set { SetProperty(ref _useAzureServices, value); UpdateUseAzureServices(); } } public string TitleUseFakeLocation => !UseFakeLocation ? "Use Real Location" : "Use Fake Location"; public string DescriptionUseFakeLocation => !UseFakeLocation ? "When enabling location, the app will attempt to use the location from the device." : "Fake Location data is added for marketing campaign testing."; public bool UseFakeLocation { get => _useFakeLocation; set { SetProperty(ref _useFakeLocation, value); UpdateFakeLocation(); } } public string TitleAllowGpsLocation => !AllowGpsLocation ? "GPS Location Disabled" : "GPS Location Enabled"; public string DescriptionAllowGpsLocation => !AllowGpsLocation ? "When disabling location, you won't receive location campaigns based upon your location." : "When enabling location, you'll receive location campaigns based upon your location."; public string GpsWarningMessage { get => _gpsWarningMessage; set => SetProperty(ref _gpsWarningMessage, value); } public string IdentityEndpoint { get => _identityEndpoint; set { SetProperty(ref _identityEndpoint, value); if (!string.IsNullOrEmpty(value)) { UpdateIdentityEndpoint(); } } } public string GatewayCatalogEndpoint { get => _gatewayCatalogEndpoint; set { SetProperty(ref _gatewayCatalogEndpoint, value); if (!string.IsNullOrEmpty(value)) { UpdateGatewayShoppingEndpoint(); } } } public string GatewayOrdersEndpoint { get => _gatewayOrdersEndpoint; set { SetProperty(ref _gatewayOrdersEndpoint, value); if (!string.IsNullOrEmpty(value)) { UpdateGatewayOrdersEndpoint(); } } } public string GatewayBasketEndpoint { get => _gatewayBasketEndpoint; set { SetProperty(ref _gatewayBasketEndpoint, value); if (!string.IsNullOrEmpty(value)) { UpdateGatewayBasketEndpoint(); } } } public double Latitude { get => _latitude; set { SetProperty(ref _latitude, value); UpdateLatitude(); } } public double Longitude { get => _longitude; set { SetProperty(ref _longitude, value); UpdateLongitude(); } } public bool AllowGpsLocation { get => _allowGpsLocation; set => SetProperty(ref _allowGpsLocation, value); } public ICommand ToggleMockServicesCommand { get; } public ICommand ToggleFakeLocationCommand { get; } public ICommand ToggleSendLocationCommand { get; } public ICommand ToggleAllowGpsLocationCommand { get; } protected override async void OnPropertyChanged(PropertyChangedEventArgs e) { base.OnPropertyChanged(e); if (e.PropertyName == nameof(AllowGpsLocation)) { await UpdateAllowGpsLocation(); } } private void ToggleMockServices() { _appEnvironmentService.UpdateDependencies(!UseAzureServices); OnPropertyChanged(nameof(TitleUseAzureServices)); OnPropertyChanged(nameof(DescriptionUseAzureServices)); } private void ToggleFakeLocation() { _appEnvironmentService.UpdateDependencies(!UseAzureServices); OnPropertyChanged(nameof(TitleUseFakeLocation)); OnPropertyChanged(nameof(DescriptionUseFakeLocation)); } private async Task ToggleSendLocationAsync() { if (!_settingsService.UseMocks) { var locationRequest = new Location {Latitude = _latitude, Longitude = _longitude}; await _locationService.UpdateUserLocation(locationRequest); } } private void ToggleAllowGpsLocation() { OnPropertyChanged(nameof(TitleAllowGpsLocation)); OnPropertyChanged(nameof(DescriptionAllowGpsLocation)); } private void UpdateUseAzureServices() { // Save use mocks services to local storage _settingsService.UseMocks = !UseAzureServices; } private void UpdateIdentityEndpoint() { // Update remote endpoint (save to local storage) _settingsService.IdentityEndpointBase = _identityEndpoint; } private void UpdateGatewayShoppingEndpoint() { _settingsService.GatewayCatalogEndpointBase = _gatewayCatalogEndpoint; } private void UpdateGatewayOrdersEndpoint() { _settingsService.GatewayOrdersEndpointBase = _gatewayOrdersEndpoint; } private void UpdateGatewayBasketEndpoint() { _settingsService.GatewayBasketEndpointBase = _gatewayBasketEndpoint; } private void UpdateFakeLocation() { _settingsService.UseFakeLocation = _useFakeLocation; } private void UpdateLatitude() { // Update fake latitude (save to local storage) _settingsService.Latitude = _latitude.ToString(); } private void UpdateLongitude() { // Update fake longitude (save to local storage) _settingsService.Longitude = _longitude.ToString(); } private async Task UpdateAllowGpsLocation() { if (_allowGpsLocation) { bool hasWhenInUseLocationPermissions; bool hasBackgroundLocationPermissions; if (await Permissions.CheckStatusAsync() != PermissionStatus.Granted) { hasWhenInUseLocationPermissions = await Permissions.RequestAsync() == PermissionStatus.Granted; } else { hasWhenInUseLocationPermissions = true; } if (await Permissions.CheckStatusAsync() != PermissionStatus.Granted) { hasBackgroundLocationPermissions = await Permissions.RequestAsync() == PermissionStatus.Granted; } else { hasBackgroundLocationPermissions = true; } if (!hasWhenInUseLocationPermissions || !hasBackgroundLocationPermissions) { _allowGpsLocation = false; GpsWarningMessage = "Enable the GPS sensor on your device"; } else { _settingsService.AllowGpsLocation = _allowGpsLocation; GpsWarningMessage = string.Empty; } } else { _settingsService.AllowGpsLocation = _allowGpsLocation; } } } ================================================ FILE: src/ClientApp/Views/BadgeView.cs ================================================ using Microsoft.Maui.Controls.Shapes; namespace eShop.ClientApp.Views; [ContentProperty(nameof(Content))] public class BadgeView : Grid { public static BindableProperty ContentProperty = BindableProperty.Create(nameof(Content), typeof(View), typeof(BadgeView), propertyChanged: OnLayoutPropertyChanged); public static BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(BadgeView), propertyChanged: OnLayoutPropertyChanged); public static BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(BadgeView), propertyChanged: OnLayoutPropertyChanged); public static BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(double), typeof(BadgeView), 10.0d, propertyChanged: OnLayoutPropertyChanged); public static BindableProperty BadgeColorProperty = BindableProperty.Create(nameof(BadgeColor), typeof(Color), typeof(BadgeView), propertyChanged: OnLayoutPropertyChanged); private readonly Label _badgeIndicator; private readonly Border _border; private readonly RoundRectangle _borderShape; public BadgeView() { _badgeIndicator = new Label { Padding = 4, HorizontalTextAlignment = TextAlignment.Center, VerticalTextAlignment = TextAlignment.Center }; _borderShape = new RoundRectangle(); _border = new Border { StrokeShape = _borderShape, Content = _badgeIndicator, HorizontalOptions = LayoutOptions.End, VerticalOptions = LayoutOptions.Start, ZIndex = 10 }; Children.Add(_border); UpdateLayout(); } public View Content { get => (View)GetValue(ContentProperty); set => SetValue(ContentProperty, value); } public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } public Color TextColor { get => (Color)GetValue(TextColorProperty); set => SetValue(TextColorProperty, value); } public double FontSize { get => (double)GetValue(FontSizeProperty); set => SetValue(FontSizeProperty, value); } public Color BadgeColor { get => (Color)GetValue(BadgeColorProperty); set => SetValue(BadgeColorProperty, value); } private static void OnLayoutPropertyChanged(BindableObject bindable, object oldValue, object newValue) { (bindable as BadgeView)?.UpdateLayout(); } protected override void OnHandlerChanging(HandlerChangingEventArgs args) { base.OnHandlerChanging(args); _border.SizeChanged -= BadgeIndicatorSizeChanged; if (args.NewHandler is not null) { _border.SizeChanged += BadgeIndicatorSizeChanged; } } private void BadgeIndicatorSizeChanged(object sender, EventArgs e) { var halfHeight = _border.Height * .5f; _border.MinimumWidthRequest = _border.Height; _borderShape.CornerRadius = halfHeight; if (Content is not null) { Content.Margin = halfHeight; } } private void UpdateLayout() { BatchBegin(); _border.BatchBegin(); _badgeIndicator.BatchBegin(); if (Content is not null && Content.Parent != this) { Content.ZIndex = 1; Children.Add(Content); } _border.BackgroundColor = BadgeColor; _badgeIndicator.Text = Text; _badgeIndicator.TextColor = TextColor; _badgeIndicator.FontSize = FontSize; _border.BatchCommit(); _badgeIndicator.BatchCommit(); BatchCommit(); } } ================================================ FILE: src/ClientApp/Views/BasketView.xaml ================================================ ================================================ FILE: src/ClientApp/Views/FiltersView.xaml.cs ================================================ namespace eShop.ClientApp.Views; public partial class FiltersView : ContentPage { public FiltersView(CatalogViewModel viewModel) { BindingContext = viewModel; InitializeComponent(); } } ================================================ FILE: src/ClientApp/Views/LoginView.xaml ================================================
} ================================================ FILE: src/Identity.API/Views/Account/Logout.cshtml ================================================ @model LogoutViewModel

Logout

Would you like to logut of IdentityServer?

================================================ FILE: src/Identity.API/Views/Consent/Index.cshtml ================================================ @model ConsentViewModel ================================================ FILE: src/Identity.API/Views/Device/Success.cshtml ================================================

Success

You have successfully authorized the device

================================================ FILE: src/Identity.API/Views/Device/UserCodeCapture.cshtml ================================================ @model string

User Code

Please enter the code displayed on your device.

================================================ FILE: src/Identity.API/Views/Device/UserCodeConfirmation.cshtml ================================================ @model DeviceAuthorizationViewModel
@if (Model.ClientLogoUrl != null) { }

@Model.ClientName is requesting your permission

@if (Model.ConfirmUserCode) {

Please confirm that the authorization request quotes the code: @Model.UserCode.

}

Uncheck the permissions you do not wish to grant.

@if (Model.IdentityScopes.Any()) {
Personal Information
    @foreach (var scope in Model.IdentityScopes) { }
} @if (Model.ApiScopes.Any()) {
Application Access
    @foreach (var scope in Model.ApiScopes) { }
}
Description
@if (Model.AllowRememberConsent) {
}
@if (Model.ClientUrl != null) { @Model.ClientName }
================================================ FILE: src/Identity.API/Views/Diagnostics/Index.cshtml ================================================ @model DiagnosticsViewModel

Authentication Cookie

Claims

@foreach (var claim in Model.AuthenticateResult.Principal.Claims) {
@claim.Type
@claim.Value
}

Properties

@foreach (var prop in Model.AuthenticateResult.Properties.Items) {
@prop.Key
@prop.Value
} @if (Model.Clients.Any()) {
Clients
@{ var clients = Model.Clients.ToArray(); for(var i = 0; i < clients.Length; i++) { @clients[i] if (i < clients.Length - 1) { , } } }
}
================================================ FILE: src/Identity.API/Views/Grants/Index.cshtml ================================================ @model GrantsViewModel

Client Application Permissions

Below is the list of applications you have given permission to and the resources they have access to.

@if (Model.Grants.Any() == false) {
You have not given access to any applications
} else { foreach (var grant in Model.Grants) {
@if (grant.ClientLogoUrl != null) { } @grant.ClientName
    @if (grant.Description != null) {
  • @grant.Description
  • }
  • @grant.Created.ToString("yyyy-MM-dd")
  • @if (grant.Expires.HasValue) {
  • @grant.Expires.Value.ToString("yyyy-MM-dd")
  • } @if (grant.IdentityGrantNames.Any()) {
    • @foreach (var name in grant.IdentityGrantNames) {
    • @name
    • }
  • } @if (grant.ApiGrantNames.Any()) {
    • @foreach (var name in grant.ApiGrantNames) {
    • @name
    • }
  • }
} }
================================================ FILE: src/Identity.API/Views/Home/Index.cshtml ================================================ @using System.Diagnostics @{ var version = FileVersionInfo.GetVersionInfo(typeof(Duende.IdentityServer.Hosting.IdentityServerMiddleware).Assembly.Location).ProductVersion.Split('+').First(); }

Welcome to IdentityServer4 (version @version)

================================================ FILE: src/Identity.API/Views/Shared/Error.cshtml ================================================ @model ErrorViewModel @{ var error = Model?.Error?.Error; var errorDescription = Model?.Error?.ErrorDescription; var request_id = Model?.Error?.RequestId; }

Error

Sorry, there was an error @if (error != null) { : @error if (errorDescription != null) {
@errorDescription
} }
@if (request_id != null) {
Request Id: @request_id
}
================================================ FILE: src/Identity.API/Views/Shared/Redirect.cshtml ================================================ @model RedirectViewModel

You are now being returned to the application

Once complete, you may close this tab.

================================================ FILE: src/Identity.API/Views/Shared/_Layout.cshtml ================================================ @using Microsoft.Extensions.Configuration @inject IConfiguration Configuration Identity | AdventureWorks
@RenderBody()
@RenderSection("scripts", required: false) ================================================ FILE: src/Identity.API/Views/Shared/_ScopeListItem.cshtml ================================================ @model ScopeViewModel
  • @if (Model.Required) { (required) } @if (Model.Description != null) { }
  • ================================================ FILE: src/Identity.API/Views/Shared/_ValidationSummary.cshtml ================================================ @if (ViewContext.ModelState.IsValid == false) {
    Error
    } ================================================ FILE: src/Identity.API/Views/_ViewImports.cshtml ================================================ @using IdentityServerHost.Quickstart.UI @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers ================================================ FILE: src/Identity.API/Views/_ViewStart.cshtml ================================================ @{ Layout = "_Layout"; } ================================================ FILE: src/Identity.API/appsettings.Development.json ================================================ { "ConnectionStrings": { "IdentityDB": "Host=localhost;Database=IdentityDB;Username=postgres;Password=yourWeak(!)Password" } } ================================================ FILE: src/Identity.API/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "MauiCallback": "maui://authcallback", "UseCustomizationData": false, "TokenLifetimeMinutes": 120, "PermanentTokenLifetimeDays": 365 } ================================================ FILE: src/Identity.API/bundleconfig.json ================================================ // Configure bundling and minification for the project. // More info at https://go.microsoft.com/fwlink/?LinkId=808241 [ { "outputFileName": "wwwroot/css/site.min.css", // An array of relative input file paths. Globbing patterns supported "inputFiles": [ "wwwroot/css/**/*.css" ] }, { "outputFileName": "wwwroot/js/site.min.js", "inputFiles": [ "wwwroot/js/site.js" ], // Optionally specify minification options "minify": { "enabled": true, "renameLocals": true }, // Optinally generate .map file "sourceMap": false } ] ================================================ FILE: src/Identity.API/libman.json ================================================ { "version": "1.0", "defaultProvider": "cdnjs", "libraries": [ { "library": "jquery@3.6.3", "destination": "wwwroot/lib/jquery/" }, { "library": "bootstrap@5.2.3", "destination": "wwwroot/lib/bootstrap/", "files": [ "css/bootstrap.css", "css/bootstrap.css.map", "css/bootstrap.min.css", "css/bootstrap.min.css.map", "js/bootstrap.bundle.js", "js/bootstrap.bundle.min.js" ] }, { "library": "jquery-validation-unobtrusive@4.0.0", "destination": "wwwroot/lib/jquery-validation-unobtrusive/" }, { "library": "jquery-validate@1.19.5", "destination": "wwwroot/lib/jquery-validate/", "files": [ "jquery.validate.min.js", "jquery.validate.js" ] } ] } ================================================ FILE: src/Identity.API/tempkey.jwk ================================================ {"AdditionalData":{},"Alg":"RS256","Crv":null,"D":"Pf7_o1LMYwcXEWHkZ54kP4ItOYu0lhOOn4vc88XYg9XmDR8DcPxyvRLWGIuV-jSioMP59HIN5yHeDrQbG3tY2lTUynRYHdwOFVQAzQ3LpGSF4eKfMOSDU05HkWv-w2E1XWogVFAafv2F4FHeYtqWAzjjcjlYy_n0GkCcjBvIneuVqWNZQ9-eBt28cApruVfTeoE01Liz-ww4T0CQ9ujJQf9zUxhYb6WWMQ1U-A0qGK9hmh00Ymf-rxS2HuZ9o8Bps9YEvy7rCBfy6nSMB8T2I2SNCHDdlR8Oi-j_l0VQyidnfnRY0_Lio8uSKDTiUL_vBKyCREAfCEHM6TCpdixR-Q","DP":"CR-Lyp15RyQV_TmxXq2EX8mntgdnT6ROePrkNlRCSXH-AAVJQ-24OlBH0qzehzMWI9Yk72bhqZdBIC1W8SGsTxf6T3xp-MwINJwHgYftX-D9RDQCYS5o6_gFW1775Of43Zv8tsjjwwkxUKHiwH8ImbEyqv2qVhdb_yMlgFwtXCc","DQ":"ulVv9t13HB8K8jKLomSoINMaFtNGN0LS6wY93JKtwGCjcvV5lwXRiHhq0AbDOrJggg-zZVedlbXpWRokMI-hLTHc9TguYEfiJ11DvgldTB5MUfMxHau0L8ofS5YAuTSEgg4EPIrWgvVEF1ljHwLQtDSBB3CvI6UHF7rXXy9cwIk","E":"AQAB","K":null,"KeyId":"F7383A0ED7CD960EF473FD2851803938","Kid":"F7383A0ED7CD960EF473FD2851803938","Kty":"RSA","N":"p3jTfCB0YsKpqJ6CNJi0tVmFBmoyI_D7QLLsbB-TCZ3-HIXDEr_k6zKb2GJ_QP7mncdSnYpJWSv7fWPfM0bL3A6NaMLF9MDjbfD5ti9irEW1dzBvIK0YjWmfks3eq6Mb2mM6PZtNEnoCqEzjgcRkDR1vtClEzUjs1E_i7TB-Y0J_aTYpLf-eN7yA1Obu8zMVRSSVBIwG5W5jljzA2nxk2u9qeDq8Sn0qgGwbX8cyYGQoVWBOPx7zap4cNcL6dHILjnlVrHqAUW9NVXtBWlVDP1Gvnm2zhCVJT_gW1twNhswyFULGVQH1ZWI0NukEqHG6LpN8Ti7Hx-K8MEEv_vQBYw","Oth":null,"P":"1XZw-ufz_e5fWroi8naLluW5Ow1f-Ems8oG1WYUJkc1Q4vZWklLRqlEsDH48gqk0dEgXLE9tz0dDTo03tAPJmR1InFpusyLp2XPPJ4XLhWFCMDDb0xr4oayV1XDOi3U9eKERtcASo1IDo8zDEuY8NHFdJlNKpfJ7P0JSB0hEwoc","Q":"yNg4tO9PVrwQ0CaouP0jniFouHAEfZMyJ0vVM1xPVim7jeKysOJi79lHO7OWNSABB3hY8dLcMSiKxS3BaU0Q2ns9Gw-NLR6eqT0BN9Nzh-a0sa0iuPUWCPgsWdrqjIf4FBX0Ra-6q7_LxuWCncehUAtoLFWMkcOrEj03Gwz1lUU","QI":"UWdYA2qY622GL97_tPvrgk1VWSqoO2a0-TlGu0gK51T_Z1hqLwzG9QpTujoxnRU7Js_QUe_9cIEO72N0qliEm6A-MClM-5LUA9XleyAosb1cblLjAPqT9gfGbRRAbB3Aj3YwsgASGUiSpGVfaUuXn-OnuLoidZQXw7w9xcPuxMo","Use":null,"X":null,"X5t":null,"X5tS256":null,"X5u":null,"Y":null,"KeySize":2048,"HasPrivateKey":true,"CryptoProviderFactory":{"CryptoProviderCache":{},"CustomCryptoProvider":null,"CacheSignatureProviders":true,"SignatureProviderObjectPoolCacheSize":16}} ================================================ FILE: src/Identity.API/wwwroot/_references.js ================================================ /// /// /// /// /// /// ================================================ FILE: src/Identity.API/wwwroot/css/site.css ================================================ /* plus-jakarta-sans-200 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 200; src: url('../fonts/plus-jakarta-sans-v8-latin-200.woff2') format('woff2'); } /* plus-jakarta-sans-200italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 200; src: url('../fonts/plus-jakarta-sans-v8-latin-200italic.woff2') format('woff2'); } /* plus-jakarta-sans-300 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 300; src: url('../fonts/plus-jakarta-sans-v8-latin-300.woff2') format('woff2'); } /* plus-jakarta-sans-300italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 300; src: url('../fonts/plus-jakarta-sans-v8-latin-300italic.woff2') format('woff2'); } /* plus-jakarta-sans-regular - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 400; src: url('../fonts/plus-jakarta-sans-v8-latin-regular.woff2') format('woff2'); } /* plus-jakarta-sans-italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 400; src: url('../fonts/plus-jakarta-sans-v8-latin-italic.woff2') format('woff2'); } /* plus-jakarta-sans-500 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 500; src: url('../fonts/plus-jakarta-sans-v8-latin-500.woff2') format('woff2'); } /* plus-jakarta-sans-500italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 500; src: url('../fonts/plus-jakarta-sans-v8-latin-500italic.woff2') format('woff2'); } /* plus-jakarta-sans-600 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 600; src: url('../fonts/plus-jakarta-sans-v8-latin-600.woff2') format('woff2'); } /* plus-jakarta-sans-600italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 600; src: url('../fonts/plus-jakarta-sans-v8-latin-600italic.woff2') format('woff2'); } /* plus-jakarta-sans-700 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 700; src: url('../fonts/plus-jakarta-sans-v8-latin-700.woff2') format('woff2'); } /* plus-jakarta-sans-700italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 700; src: url('../fonts/plus-jakarta-sans-v8-latin-700italic.woff2') format('woff2'); } /* plus-jakarta-sans-800 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 800; src: url('../fonts/plus-jakarta-sans-v8-latin-800.woff2') format('woff2'); } /* plus-jakarta-sans-800italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 800; src: url('../fonts/plus-jakarta-sans-v8-latin-800italic.woff2') format('woff2'); } body { margin-top: 0; font-family: 'Plus Jakarta Sans'; min-width:480px; } .content { position: relative; } .welcome-page { background: #fff; max-width: 60rem; margin: auto; padding: 3rem; } .eshop-banner { display: flex; justify-content: center; } .eshop-banner img { position: absolute; width: 100%; max-width: 1920px; } .eshop-header { position: relative; max-width: 120rem; margin: auto; } .eshop-header.home .eshop-header-container { height: 38rem; margin-bottom: 0; } .eshop-header .eshop-header-container { margin-bottom: 4rem; } .eshop-header-hero { overflow: hidden; position: absolute; max-width: 100%; left: 0; top: 0; } .eshop-header-container { position: relative; margin: auto; margin: 0 10rem; } .eshop-header-intro { position: absolute; max-width: 48rem; bottom: 3rem; } .eshop-header-intro h1 { color: #000; font-size: 3.5rem; font-style: normal; font-weight: 700; line-height: 100%; margin: 0; } .eshop-header-intro p { color: #000; font-size: 2rem; font-style: normal; font-weight: 700; line-height: 125%; margin: 0; } .eshop-header .logo-header { color: black; margin-right: auto; } .eshop-header-navbar { display: flex; flex-direction: row; justify-content: flex-end; align-items: baseline; margin-top: 1.25rem; gap: 1.5rem; } .eshop-footer { margin-top: 3.5rem; background-color: #000; width: 100%; } .eshop-footer-content { max-width: 120rem; margin: auto; } .eshop-footer-row { padding: 3.5rem 10rem; color: white; display: flex; justify-content: flex-end; align-items: baseline; } .eshop-footer .logo-footer { color: white; margin-right: auto; } .login-container { position: relative; display: flex; padding: 40px; flex-direction: column; justify-content: flex-end; gap: 20px; flex: 1 0 0; align-self: stretch; background: #FFF; box-shadow: 0px 3.38082px 3.38082px 0px rgba(0, 0, 0, 0.01), 0px 27px 27px 0px rgba(0, 0, 0, 0.03); } .login-container h2 { color: #000; font-size: 36px; font-style: normal; font-weight: 700; line-height: 48px; } .login-container label { color: #444; font-size: 14px; font-style: normal; font-weight: 400; line-height: 20px; } .login-container input { border: 1px solid #000; background: #FFF; max-width: 100%; border-radius: 0; color: #000; } .login-container input { border: 1px solid #000; background: #FFF; max-width: 100%; border-radius: 0; color: #000; } .login-container .form-check { display: flex; align-items: center; gap: 8px; } .login-container .form-check-input[type=checkbox] { border-radius: 0; width: 20px; height: 20px; } .login-container .form-check-input:checked[type=checkbox] { background-image: url('../images/check.svg'); background-size: contain; } .login-container .btn.btn-primary { border-radius: 0; display: flex; padding: 12px 24px; justify-content: center; align-items: flex-end; gap: 8px; align-self: stretch; background: #000; } .icon { position: relative; top: -10px; } .page-consent .client-logo { float: left; } .page-consent .client-logo img { width: 80px; height: 80px; } .page-consent .consent-buttons { margin-top: 25px; } .page-consent .consent-form .consent-scopecheck { display: inline-block; margin-right: 5px; } .page-consent .consent-form .consent-description { margin-left: 25px; } .page-consent .consent-form .consent-description label { font-weight: normal; } .page-consent .consent-form .consent-remember { padding-left: 16px; } .mt-15 { margin-top:15px; } /* Wrapping element */ /* Set some basic padding to keep content from hitting the edges */ .body-content { padding-left: 15px; padding-right: 15px; } /* Set widths on the form inputs since otherwise they're 100% wide */ input, select, textarea { max-width: 280px; } .select-filter { background-color: transparent; padding: 10px; margin: 10px; margin-right: 20px; color: white; padding-top: 20px; padding-bottom: 3px; min-width: 140px; border-color: #37c7ca; max-height: 43px; -webkit-appearance: none; } .select-filter option { background-color: #00a69c; } select::-ms-expand { display: none; } .select-filter-wrapper { z-index: 0; display:inline-block; margin-left: -10px; } .select-filter-wrapper::before { content: attr(data-name); opacity: 0.5; z-index: 1; text-transform: uppercase; position: absolute; font-size: 10px; margin-top: 15px; margin-left: 21px; color: white; } .select-filter-arrow { position: absolute; margin-left: 130px; margin-top: 40px; } .btn-brand-small-filter { margin-top: 10px; position: absolute; margin-left: 15px; } /* Carousel */ .carousel-caption p { font-size: 20px; line-height: 1.4; } .layout-cart-image { height: 36px; margin-top: 5px; } .layout-cart-badge { position: absolute; margin-top: 2px; margin-left: 14px; background-color: #83d01b; padding: 1px; color: white; border-radius: 50%; width: 18px; height: 18px; font-size: 12px; cursor: pointer; } /* buttons and links extension to use brackets: [ click me ] */ .btn-bracketed:hover:before { display: inline-block; content: "["; padding-right: 0.5em; color: chartreuse; } .btn-bracketed:hover:after { display: inline-block; content: "]"; padding-left: 0.5em; color: chartreuse; } .btn-brand { background-color: #83D01B; color: white; padding: 10px 20px 10px 20px; border-radius: 0px; border: none; width: 255px; display: inline-block; text-align: center; text-transform: uppercase; height: 45px; font-size: 16px; font-weight: normal; } .btn-brand::before { content: '[' } .btn-brand::after { content: ']' } .btn-brand:hover:before { padding-right: 5px; } .btn-brand:hover:after { padding-left: 5px; } .btn-brand-big { width: 360px; margin-top: 20px; } .btn-brand-small { width: 45px; } .btn-brand-small::before { content: ''; } .btn-brand-small::after { content: ''; } .btn-brand-small:hover:before { content: ''; padding: 0; } .btn-brand-small:hover:after { content: ''; padding: 0; } .btn-brand-dark { background-color: #00a69c; } .btn-brand:hover { color: white; background-color: #83D01B; text-decoration:none; } .btn-brand-dark:hover { background-color: #00a69c; } .btn-cart { float: right; margin-top: 40px; margin-bottom: 40px; } .btn-catalog-apply { padding:0; } .form-label { text-transform: uppercase; font-weight: normal!important; text-align: left; margin-bottom: 10px !important; color: #404040; } .form-input { border-radius: 0; padding: 10px; height: 45px; } .form-input-small { max-width: 100px!important; } .form-select { border-radius: 0; padding: 10px; height: 45px; width: 150px; } /* Make .svg files in the carousel display properly in older browsers */ .carousel-inner .item img[src$=".svg"] { width: 100%; } .navbar-inverse { background-color: #FFF; border-color: #FFF; } /*.navbar-inverse li { margin-top: 10px; }*/ .btn-login { border: 1px solid #00A69C; height: 36px!important; margin-right: 10px; margin-top: 10px; background-color: white; color: #00a69c; text-transform:uppercase; max-width: 140px; width: 140px; padding-top:8px!important; } .btn-login { font-weight:normal!important; } .btn-login::before { content: '['; } .btn-login::after { content: ']'; } .btn-login:hover:before { content: '[ '; } .btn-login:hover:after { content: ' ]'; } .navbar-inverse li a { height: 30px; padding: 5px 20px; color: #00A69C !important; } .navbar-brand { margin-top: 20px; background-image: url(../images/brand.PNG); width: 201px; height: 44px; margin-left: 0px !important; } .nav > li > a { color: white; } .nav > li > a:hover, .nav > li > a:focus { background-color: #00A69C; font-weight: bolder; } .container-fluid { padding-left: 0px; padding-right: 0px; } .home-banner { width: 100%; margin-right: 0px; margin-left: 0px; background-image: url(../images/main_banner.png); background-size: cover; height: 258px; background-position: center; } .home-banner-text { margin-top: 70px; } .home-catalog-container { min-height: 400px; margin-bottom: 20px; } .home-catalog-filter-container { background-color: #00A69C; height:63px; line-height: 76px; } .home-catalog-filter-container li a { padding-top: 5px !important; } .home-catalog-filter-brands::before { content: 'BRAND'; color: white; font-size: x-small; opacity: 0.5; margin: 10px 0px 0px 15px; } .home-catalog-filter-types::before { content: 'TYPES'; color: white; font-size: x-small; opacity: 0.5; margin: 10px 0px 0px 15px; } .home-catalog-item { margin-top: 10px; margin-bottom: 10px; } .home-catalog-item-image { width: 100%; object-fit: cover; /* max-width: 320px; */ text-align: center; } .home-catalog-item-image-addCart { background-color: #83D01B; color: white; display: inline-block; height: 43px; padding: 10px 20px 10px 20px; font-weight: bold; text-align: center; margin-top: 10px; margin-left: 60px; margin-right: 60px; font-size: 16px; font-weight: normal; } .home-catalog-item-image-addCart:hover { color: white; text-decoration: none; } .home-catalog-item-image:hover:after { cursor: pointer; } .home-catalog-item-title { text-align: center; text-transform: uppercase; font-weight: 300; font-size: 16px; margin-top: 20px; } .home-catalog-item-price { text-align: center; font-weight: 900; font-size: 28px; } .home-catalog-item-price::before { content: '$'; } .home-catalog-noResults { text-align:center; margin-top: 100px; } .container .nav .navbar-nav .col-sm-6 ::before { content: 'BRAND'; } .validation-summary-errors li { list-style: none; } .text { color: #83D01B; } .text:hover { color: #83D01B; } form .col-md-4 { text-align: right; } .account-login-container { min-height: 70vh; text-align: center; padding-top: 40px; } .account-register-container { min-height: 70vh; text-align: center !important; align-content: center; } .cart-index-container { min-height: 70vh; padding-top: 40px; margin-bottom: 30px; min-width: 992px; } .register-container { min-height: 70vh; padding-top: 40px; margin-bottom: 30px; padding-left: 30px; } .order-create-container { min-height: 70vh; padding-top: 40px; margin-bottom: 30px; padding-left: 30px; min-width: 995px; } .cart-product-column { max-width: 120px; text-transform: uppercase; vertical-align: middle!important; } .order-create-container .cart-product-column { max-width: 130px; } .cart-product-column-name { width: 220px; } .cart-subtotal-label { font-size: 12px; color: #404040; margin-top:10px; } .cart-subtotal-value { font-size: 20px; color: #00a69c; } .cart-total-label { font-size: 14px; color: #404040; margin-top:10px; } .cart-total-value { font-size: 28px; color: #00a69c; text-align: left; } .cart-product-image { max-width: 210px; } .cart-section-total { margin-bottom: 5px; margin-left: 175px; text-align: left; } .cart-product-column input { width: 70px; text-align: center; } .cart-refresh-button { margin-top:0; background-image: url('../images/refresh.svg'); color: white; font-size: 8px; width: 40px; height: 40px; background-color:transparent; border:none; margin-top: 25px; margin-left:15px; } .cart-refresh-button:hover { background-color:transparent; } .cart-totals { border-bottom:none!important; } .input-validation-error { border: 1px solid #fb0d0d; } .text-danger { color: #fb0d0d; font-size: 12px; } .cart { border: none !important; } .form-horizontal h4 { margin-top: 30px; } .form-horizontal .form-group { margin-right: 0px!important; } .form-input-center { margin: auto; } .order-index-container { min-height: 70vh; padding-top: 40px; margin-bottom: 30px; } .order-index-container .table tbody tr { border-bottom:none; } .order-index-container .table tbody tr td { border-top:none; padding-top:10px; padding-bottom:10px; } .order-index-container .table tbody tr:nth-child(even) { background-color: #f5f5f5; } .order-create-section-title { margin-left: -15px; text-transform: uppercase; } .order-create-section-items { margin-left: -45px; width: 102%; } .order-detail-button { } .order-detail-button a { color: #83d01b; } .order-detail-container { min-height: 70vh; padding-top: 40px; margin-bottom: 30px; } .order-detail-container .table tbody tr:first-child td{ border-top:none; } .order-detail-container .table tr{ border-bottom:none; } .order-detail-section { margin-top: 50px; } .order-detail-container .table { margin-left: -7px; } .order-section-total { margin-bottom: 5px; margin-left: 40px; text-align: left; } .fr { float:right!important; } .down-arrow { background-image: url('../images/arrow-down.png'); height: 7px; width: 10px; display: inline-block; margin-left: 20px; } .logout-icon { background-image: url('../images/logout.PNG'); display: inline-block; height:19px; width:19px; margin-left: 15px; } .myorders-icon { background-image: url('../images/my_orders.PNG'); display: inline-block; height: 20px; width: 20px; margin-left: 15px; } .login-user { position: absolute!important; top: 30px; right: 65px; cursor:pointer; } .login-user-dropdown { position: relative; display: inline-block; } .login-user-dropdown-content { display: none; position: absolute; background-color: #FFFFFF; min-width: 160px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); /*left: 100px;*/ right: 0px; } .login-user-dropdown-content a { color: black; padding: 12px 16px; text-decoration: none; display: block; text-align:right; text-transform:uppercase; } .login-user:hover .login-user-dropdown-content { display: block; } .down-arrow:hover > .login-user-dropdown-content { display: block; } .login-user-dropdown-content a:hover { color: #83d01b; } .es-header { min-height: 80px!important; } .es-pager-bottom { margin-top: 40px; } .es-pager-top { margin-bottom: 20px; margin-top: 20px; } .es-pager-top ul { list-style: none; } .es-pager-bottom ul { list-style: none; } .page-item { cursor: pointer; } .next { position: absolute; right: 15px; top: 0; } .previous { position: absolute; left: 0; top: 0; } .is-disabled{ cursor: not-allowed; opacity: .5; pointer-events: none; } .table tr { border-bottom:1px solid #ddd; } .table th { text-transform: uppercase; } .navbar-nav { margin-top: 10px; margin-bottom: 7.5px; margin-right: -10px; float: right; } @media screen and (max-width: 1195px) { .cart-product-column-name { display:none; } } /* Hide/rearrange for smaller screens */ @media screen and (max-width: 767px) { /* Hide captions */ .carousel-caption { display: none; } footer .text { text-align: left; margin-top: -15px; } .cart-product-column-brand { display:none; } } @media screen and (min-width: 992px) { .form-input { width: 360px; max-width: 360px; } } @media screen and (max-width: 415px) { .account-login-container { margin-left: -50px; } .page-consent { margin-left: 10px; margin-right: 80px; padding-right: 0px; padding-left: 0px; } } ================================================ FILE: src/Identity.API/wwwroot/js/signin-redirect.js ================================================ window.location.href = document.querySelector("meta[http-equiv=refresh]").getAttribute("data-url"); ================================================ FILE: src/Identity.API/wwwroot/js/signout-redirect.js ================================================ window.addEventListener("load", function () { var a = document.querySelector("a.PostLogoutRedirectUri"); if (a) { window.location = a.href; } }); ================================================ FILE: src/Identity.API/wwwroot/js/site.js ================================================ // Write your Javascript code. ================================================ FILE: src/IntegrationEventLogEF/EventStateEnum.cs ================================================ namespace eShop.IntegrationEventLogEF; public enum EventStateEnum { NotPublished = 0, InProgress = 1, Published = 2, PublishedFailed = 3 } ================================================ FILE: src/IntegrationEventLogEF/GlobalUsings.cs ================================================ global using System.ComponentModel.DataAnnotations.Schema; global using System.Data.Common; global using System.Reflection; global using System.Text.Json; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.EntityFrameworkCore.Storage; global using eShop.EventBus.Events; ================================================ FILE: src/IntegrationEventLogEF/IntegrationEventLogEF.csproj ================================================  net10.0 eShop.IntegrationEventLogEF false ================================================ FILE: src/IntegrationEventLogEF/IntegrationEventLogEntry.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.IntegrationEventLogEF; public class IntegrationEventLogEntry { private static readonly JsonSerializerOptions s_indentedOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions s_caseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true }; private IntegrationEventLogEntry() { } public IntegrationEventLogEntry(IntegrationEvent @event, Guid transactionId) { EventId = @event.Id; CreationTime = @event.CreationDate; EventTypeName = @event.GetType().FullName; Content = JsonSerializer.Serialize(@event, @event.GetType(), s_indentedOptions); State = EventStateEnum.NotPublished; TimesSent = 0; TransactionId = transactionId; } public Guid EventId { get; private set; } [Required] public string EventTypeName { get; private set; } [NotMapped] public string EventTypeShortName => EventTypeName.Split('.')?.Last(); [NotMapped] public IntegrationEvent IntegrationEvent { get; private set; } public EventStateEnum State { get; set; } public int TimesSent { get; set; } public DateTime CreationTime { get; private set; } [Required] public string Content { get; private set; } public Guid TransactionId { get; private set; } public IntegrationEventLogEntry DeserializeJsonContent(Type type) { IntegrationEvent = JsonSerializer.Deserialize(Content, type, s_caseInsensitiveOptions) as IntegrationEvent; return this; } } ================================================ FILE: src/IntegrationEventLogEF/IntegrationLogExtensions.cs ================================================ namespace eShop.IntegrationEventLogEF; public static class IntegrationLogExtensions { public static void UseIntegrationEventLogs(this ModelBuilder builder) { builder.Entity(builder => { builder.ToTable("IntegrationEventLog"); builder.HasKey(e => e.EventId); }); } } ================================================ FILE: src/IntegrationEventLogEF/Services/IIntegrationEventLogService.cs ================================================ namespace eShop.IntegrationEventLogEF.Services; public interface IIntegrationEventLogService { Task> RetrieveEventLogsPendingToPublishAsync(Guid transactionId); Task SaveEventAsync(IntegrationEvent @event, IDbContextTransaction transaction); Task MarkEventAsPublishedAsync(Guid eventId); Task MarkEventAsInProgressAsync(Guid eventId); Task MarkEventAsFailedAsync(Guid eventId); } ================================================ FILE: src/IntegrationEventLogEF/Services/IntegrationEventLogService.cs ================================================ namespace eShop.IntegrationEventLogEF.Services; public class IntegrationEventLogService : IIntegrationEventLogService, IDisposable where TContext : DbContext { private volatile bool _disposedValue; private readonly TContext _context; private readonly Type[] _eventTypes; public IntegrationEventLogService(TContext context) { _context = context; _eventTypes = Assembly.Load(Assembly.GetEntryAssembly().FullName) .GetTypes() .Where(t => t.Name.EndsWith(nameof(IntegrationEvent))) .ToArray(); } public async Task> RetrieveEventLogsPendingToPublishAsync(Guid transactionId) { var result = await _context.Set() .Where(e => e.TransactionId == transactionId && e.State == EventStateEnum.NotPublished) .ToListAsync(); if (result.Count != 0) { return result.OrderBy(o => o.CreationTime) .Select(e => e.DeserializeJsonContent(_eventTypes.FirstOrDefault(t => t.Name == e.EventTypeShortName))); } return []; } public Task SaveEventAsync(IntegrationEvent @event, IDbContextTransaction transaction) { if (transaction == null) throw new ArgumentNullException(nameof(transaction)); var eventLogEntry = new IntegrationEventLogEntry(@event, transaction.TransactionId); _context.Database.UseTransaction(transaction.GetDbTransaction()); _context.Set().Add(eventLogEntry); return _context.SaveChangesAsync(); } public Task MarkEventAsPublishedAsync(Guid eventId) { return UpdateEventStatus(eventId, EventStateEnum.Published); } public Task MarkEventAsInProgressAsync(Guid eventId) { return UpdateEventStatus(eventId, EventStateEnum.InProgress); } public Task MarkEventAsFailedAsync(Guid eventId) { return UpdateEventStatus(eventId, EventStateEnum.PublishedFailed); } private Task UpdateEventStatus(Guid eventId, EventStateEnum status) { var eventLogEntry = _context.Set().Single(ie => ie.EventId == eventId); eventLogEntry.State = status; if (status == EventStateEnum.InProgress) eventLogEntry.TimesSent++; return _context.SaveChangesAsync(); } protected virtual void Dispose(bool disposing) { if (!_disposedValue) { if (disposing) { _context.Dispose(); } _disposedValue = true; } } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } } ================================================ FILE: src/IntegrationEventLogEF/Utilities/ResilientTransaction.cs ================================================ namespace eShop.IntegrationEventLogEF.Utilities; public class ResilientTransaction { private readonly DbContext _context; private ResilientTransaction(DbContext context) => _context = context ?? throw new ArgumentNullException(nameof(context)); public static ResilientTransaction New(DbContext context) => new(context); public async Task ExecuteAsync(Func action) { //Use of an EF Core resiliency strategy when using multiple DbContexts within an explicit BeginTransaction(): //See: https://docs.microsoft.com/en-us/ef/core/miscellaneous/connection-resiliency var strategy = _context.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { await using var transaction = await _context.Database.BeginTransactionAsync(); await action(); await transaction.CommitAsync(); }); } } ================================================ FILE: src/OrderProcessor/BackgroundTaskOptions.cs ================================================ namespace eShop.OrderProcessor; public class BackgroundTaskOptions { public int GracePeriodTime { get; set; } public int CheckUpdateTime { get; set; } } ================================================ FILE: src/OrderProcessor/Events/GracePeriodConfirmedIntegrationEvent.cs ================================================ namespace eShop.OrderProcessor.Events { using eShop.EventBus.Events; public record GracePeriodConfirmedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public GracePeriodConfirmedIntegrationEvent(int orderId) => OrderId = orderId; } } ================================================ FILE: src/OrderProcessor/Extensions/Extensions.cs ================================================ using System.Text.Json.Serialization; using eShop.OrderProcessor.Events; namespace eShop.OrderProcessor.Extensions; public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddRabbitMqEventBus("eventbus") .ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default)); builder.AddNpgsqlDataSource("orderingdb"); builder.Services.AddOptions() .BindConfiguration(nameof(BackgroundTaskOptions)); builder.Services.AddHostedService(); } } [JsonSerializable(typeof(GracePeriodConfirmedIntegrationEvent))] partial class IntegrationEventContext : JsonSerializerContext { } ================================================ FILE: src/OrderProcessor/GlobalUsings.cs ================================================ global using Microsoft.AspNetCore.Builder; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; global using eShop.OrderProcessor.Extensions; global using eShop.OrderProcessor.Services; global using eShop.ServiceDefaults; ================================================ FILE: src/OrderProcessor/OrderProcessor.csproj ================================================  net10.0 true true ================================================ FILE: src/OrderProcessor/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddBasicServiceDefaults(); builder.AddApplicationServices(); var app = builder.Build(); app.MapDefaultEndpoints(); await app.RunAsync(); ================================================ FILE: src/OrderProcessor/Properties/launchSettings.json ================================================ { "profiles": { "OrderProcessor": { "commandName": "Project", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" }, "applicationUrl": "http://localhost:16888" } } } ================================================ FILE: src/OrderProcessor/Services/GracePeriodManagerService.cs ================================================ using eShop.EventBus.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using eShop.OrderProcessor.Events; namespace eShop.OrderProcessor.Services { public class GracePeriodManagerService( IOptions options, IEventBus eventBus, ILogger logger, NpgsqlDataSource dataSource) : BackgroundService { private readonly BackgroundTaskOptions _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var delayTime = TimeSpan.FromSeconds(_options.CheckUpdateTime); if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("GracePeriodManagerService is starting."); stoppingToken.Register(() => logger.LogDebug("GracePeriodManagerService background task is stopping.")); } while (!stoppingToken.IsCancellationRequested) { if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("GracePeriodManagerService background task is doing background work."); } await CheckConfirmedGracePeriodOrders(); await Task.Delay(delayTime, stoppingToken); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("GracePeriodManagerService background task is stopping."); } } private async Task CheckConfirmedGracePeriodOrders() { if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("Checking confirmed grace period orders"); } var orderIds = await GetConfirmedGracePeriodOrders(); foreach (var orderId in orderIds) { var confirmGracePeriodEvent = new GracePeriodConfirmedIntegrationEvent(orderId); logger.LogInformation("Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", confirmGracePeriodEvent.Id, confirmGracePeriodEvent); await eventBus.PublishAsync(confirmGracePeriodEvent); } } private async ValueTask> GetConfirmedGracePeriodOrders() { try { using var conn = dataSource.CreateConnection(); using var command = conn.CreateCommand(); command.CommandText = """ SELECT "Id" FROM ordering.orders WHERE CURRENT_TIMESTAMP - "OrderDate" >= @GracePeriodTime AND "OrderStatus" = 'Submitted' """; command.Parameters.AddWithValue("GracePeriodTime", TimeSpan.FromMinutes(_options.GracePeriodTime)); List ids = []; await conn.OpenAsync(); using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { ids.Add(reader.GetInt32(0)); } return ids; } catch (NpgsqlException exception) { logger.LogError(exception, "Fatal error establishing database connection"); } return []; } } } ================================================ FILE: src/OrderProcessor/appsettings.Development.json ================================================ { "Logging": { "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } }, "ConnectionStrings": { "postgres": "Host=localhost;Database=OrderingDB;Username=postgres;Password=yourWeak(!)Password" } } ================================================ FILE: src/OrderProcessor/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "EventBus": "amqp://localhost" }, "EventBus": { "SubscriptionClientName": "OrderProcessor" }, "BackgroundTaskOptions": { "GracePeriodTime": "1", "CheckUpdateTime": "30" } } ================================================ FILE: src/Ordering.API/Apis/OrderServices.cs ================================================ public class OrderServices( IMediator mediator, IOrderQueries queries, IIdentityService identityService, ILogger logger) { public IMediator Mediator { get; set; } = mediator; public ILogger Logger { get; } = logger; public IOrderQueries Queries { get; } = queries; public IIdentityService IdentityService { get; } = identityService; } ================================================ FILE: src/Ordering.API/Apis/OrdersApi.cs ================================================ using Microsoft.AspNetCore.Http.HttpResults; using CardType = eShop.Ordering.API.Application.Queries.CardType; using Order = eShop.Ordering.API.Application.Queries.Order; public static class OrdersApi { public static RouteGroupBuilder MapOrdersApiV1(this IEndpointRouteBuilder app) { var api = app.MapGroup("api/orders").HasApiVersion(1.0); api.MapPut("/cancel", CancelOrderAsync); api.MapPut("/ship", ShipOrderAsync); api.MapGet("{orderId:int}", GetOrderAsync); api.MapGet("/", GetOrdersByUserAsync); api.MapGet("/cardtypes", GetCardTypesAsync); api.MapPost("/draft", CreateOrderDraftAsync); api.MapPost("/", CreateOrderAsync); return api; } public static async Task, ProblemHttpResult>> CancelOrderAsync( [FromHeader(Name = "x-requestid")] Guid requestId, CancelOrderCommand command, [AsParameters] OrderServices services) { if (requestId == Guid.Empty) { return TypedResults.BadRequest("Empty GUID is not valid for request ID"); } var requestCancelOrder = new IdentifiedCommand(command, requestId); services.Logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", requestCancelOrder.GetGenericTypeName(), nameof(requestCancelOrder.Command.OrderNumber), requestCancelOrder.Command.OrderNumber, requestCancelOrder); var commandResult = await services.Mediator.Send(requestCancelOrder); if (!commandResult) { return TypedResults.Problem(detail: "Cancel order failed to process.", statusCode: 500); } return TypedResults.Ok(); } public static async Task, ProblemHttpResult>> ShipOrderAsync( [FromHeader(Name = "x-requestid")] Guid requestId, ShipOrderCommand command, [AsParameters] OrderServices services) { if (requestId == Guid.Empty) { return TypedResults.BadRequest("Empty GUID is not valid for request ID"); } var requestShipOrder = new IdentifiedCommand(command, requestId); services.Logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", requestShipOrder.GetGenericTypeName(), nameof(requestShipOrder.Command.OrderNumber), requestShipOrder.Command.OrderNumber, requestShipOrder); var commandResult = await services.Mediator.Send(requestShipOrder); if (!commandResult) { return TypedResults.Problem(detail: "Ship order failed to process.", statusCode: 500); } return TypedResults.Ok(); } public static async Task, NotFound>> GetOrderAsync(int orderId, [AsParameters] OrderServices services) { try { var order = await services.Queries.GetOrderAsync(orderId); return TypedResults.Ok(order); } catch { return TypedResults.NotFound(); } } public static async Task>> GetOrdersByUserAsync([AsParameters] OrderServices services) { var userId = services.IdentityService.GetUserIdentity(); var orders = await services.Queries.GetOrdersFromUserAsync(userId); return TypedResults.Ok(orders); } public static async Task>> GetCardTypesAsync(IOrderQueries orderQueries) { var cardTypes = await orderQueries.GetCardTypesAsync(); return TypedResults.Ok(cardTypes); } public static async Task CreateOrderDraftAsync(CreateOrderDraftCommand command, [AsParameters] OrderServices services) { services.Logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.BuyerId), command.BuyerId, command); return await services.Mediator.Send(command); } public static async Task>> CreateOrderAsync( [FromHeader(Name = "x-requestid")] Guid requestId, CreateOrderRequest request, [AsParameters] OrderServices services) { //mask the credit card number services.Logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId}", request.GetGenericTypeName(), nameof(request.UserId), request.UserId); //don't log the request as it has CC number if (requestId == Guid.Empty) { services.Logger.LogWarning("Invalid IntegrationEvent - RequestId is missing - {@IntegrationEvent}", request); return TypedResults.BadRequest("RequestId is missing."); } using (services.Logger.BeginScope(new List> { new("IdentifiedCommandId", requestId) })) { var maskedCCNumber = request.CardNumber.Substring(request.CardNumber.Length - 4).PadLeft(request.CardNumber.Length, 'X'); var createOrderCommand = new CreateOrderCommand(request.Items, request.UserId, request.UserName, request.City, request.Street, request.State, request.Country, request.ZipCode, maskedCCNumber, request.CardHolderName, request.CardExpiration, request.CardSecurityNumber, request.CardTypeId); var requestCreateOrder = new IdentifiedCommand(createOrderCommand, requestId); services.Logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", requestCreateOrder.GetGenericTypeName(), nameof(requestCreateOrder.Id), requestCreateOrder.Id, requestCreateOrder); var result = await services.Mediator.Send(requestCreateOrder); if (result) { services.Logger.LogInformation("CreateOrderCommand succeeded - RequestId: {RequestId}", requestId); } else { services.Logger.LogWarning("CreateOrderCommand failed - RequestId: {RequestId}", requestId); } return TypedResults.Ok(); } } } public record CreateOrderRequest( string UserId, string UserName, string City, string Street, string State, string Country, string ZipCode, string CardNumber, string CardHolderName, DateTime CardExpiration, string CardSecurityNumber, int CardTypeId, string Buyer, List Items); ================================================ FILE: src/Ordering.API/Application/Behaviors/LoggingBehavior.cs ================================================ namespace eShop.Ordering.API.Application.Behaviors; public class LoggingBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; public LoggingBehavior(ILogger> logger) => _logger = logger; public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { _logger.LogInformation("Handling command {CommandName} ({@Command})", request.GetGenericTypeName(), request); var response = await next(); _logger.LogInformation("Command {CommandName} handled - response: {@Response}", request.GetGenericTypeName(), response); return response; } } ================================================ FILE: src/Ordering.API/Application/Behaviors/TransactionBehavior.cs ================================================ namespace eShop.Ordering.API.Application.Behaviors; using Microsoft.Extensions.Logging; public class TransactionBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; private readonly OrderingContext _dbContext; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public TransactionBehavior(OrderingContext dbContext, IOrderingIntegrationEventService orderingIntegrationEventService, ILogger> logger) { _dbContext = dbContext ?? throw new ArgumentException(nameof(OrderingContext)); _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentException(nameof(orderingIntegrationEventService)); _logger = logger ?? throw new ArgumentException(nameof(ILogger)); } public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var response = default(TResponse); var typeName = request.GetGenericTypeName(); try { if (_dbContext.HasActiveTransaction) { return await next(); } var strategy = _dbContext.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { Guid transactionId; await using var transaction = await _dbContext.BeginTransactionAsync(); using (_logger.BeginScope(new List> { new("TransactionContext", transaction.TransactionId) })) { _logger.LogInformation("Begin transaction {TransactionId} for {CommandName} ({@Command})", transaction.TransactionId, typeName, request); response = await next(); _logger.LogInformation("Commit transaction {TransactionId} for {CommandName}", transaction.TransactionId, typeName); await _dbContext.CommitTransactionAsync(transaction); transactionId = transaction.TransactionId; } await _orderingIntegrationEventService.PublishEventsThroughEventBusAsync(transactionId); }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error Handling transaction for {CommandName} ({@Command})", typeName, request); throw; } } } ================================================ FILE: src/Ordering.API/Application/Behaviors/ValidatorBehavior.cs ================================================ namespace eShop.Ordering.API.Application.Behaviors; public class ValidatorBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; private readonly IEnumerable> _validators; public ValidatorBehavior(IEnumerable> validators, ILogger> logger) { _validators = validators; _logger = logger; } public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var typeName = request.GetGenericTypeName(); _logger.LogInformation("Validating command {CommandType}", typeName); var validationTasks = _validators.Select(v => v.ValidateAsync(request, cancellationToken)); var validationResults = await Task.WhenAll(validationTasks); var failures = validationResults .SelectMany(result => result.Errors) .Where(error => error != null) .ToList(); if (failures.Any()) { _logger.LogWarning("Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}", typeName, request, failures); throw new OrderingDomainException( $"Command Validation Errors for type {typeof(TRequest).Name}", new ValidationException("Validation exception", failures)); } return await next(); } } ================================================ FILE: src/Ordering.API/Application/Commands/CancelOrderCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record CancelOrderCommand(int OrderNumber) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/CancelOrderCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class CancelOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public CancelOrderCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// customer executes cancel order from app /// /// /// public async Task Handle(CancelOrderCommand command, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetCancelledStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class CancelOrderIdentifiedCommandHandler : IdentifiedCommandHandler { public CancelOrderIdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/Commands/CreateOrderCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // DDD and CQRS patterns comment: Note that it is recommended to implement immutable Commands // In this case, its immutability is achieved by having all the setters as private // plus only being able to update the data just once, when creating the object through its constructor. // References on Immutable Commands: // http://cqrs.nu/Faq // https://docs.spine3.org/motivation/immutability.html // http://blog.gauffin.org/2012/06/griffin-container-introducing-command-support/ // https://docs.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/how-to-implement-a-lightweight-class-with-auto-implemented-properties using eShop.Ordering.API.Application.Models; using eShop.Ordering.API.Extensions; [DataContract] public class CreateOrderCommand : IRequest { [DataMember] private readonly List _orderItems; [DataMember] public string UserId { get; private set; } [DataMember] public string UserName { get; private set; } [DataMember] public string City { get; private set; } [DataMember] public string Street { get; private set; } [DataMember] public string State { get; private set; } [DataMember] public string Country { get; private set; } [DataMember] public string ZipCode { get; private set; } [DataMember] public string CardNumber { get; private set; } [DataMember] public string CardHolderName { get; private set; } [DataMember] public DateTime CardExpiration { get; private set; } [DataMember] public string CardSecurityNumber { get; private set; } [DataMember] public int CardTypeId { get; private set; } [DataMember] public IEnumerable OrderItems => _orderItems; public CreateOrderCommand() { _orderItems = new List(); } public CreateOrderCommand(List basketItems, string userId, string userName, string city, string street, string state, string country, string zipcode, string cardNumber, string cardHolderName, DateTime cardExpiration, string cardSecurityNumber, int cardTypeId) { _orderItems = basketItems.ToOrderItemsDTO().ToList(); UserId = userId; UserName = userName; City = city; Street = street; State = state; Country = country; ZipCode = zipcode; CardNumber = cardNumber; CardHolderName = cardHolderName; CardExpiration = cardExpiration; CardSecurityNumber = cardSecurityNumber; CardTypeId = cardTypeId; } } ================================================ FILE: src/Ordering.API/Application/Commands/CreateOrderCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; // Regular CommandHandler public class CreateOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; private readonly IIdentityService _identityService; private readonly IMediator _mediator; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; private readonly ILogger _logger; // Using DI to inject infrastructure persistence Repositories public CreateOrderCommandHandler(IMediator mediator, IOrderingIntegrationEventService orderingIntegrationEventService, IOrderRepository orderRepository, IIdentityService identityService, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _identityService = identityService ?? throw new ArgumentNullException(nameof(identityService)); _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task Handle(CreateOrderCommand message, CancellationToken cancellationToken) { // Add Integration event to clean the basket var orderStartedIntegrationEvent = new OrderStartedIntegrationEvent(message.UserId); await _orderingIntegrationEventService.AddAndSaveEventAsync(orderStartedIntegrationEvent); // Add/Update the Buyer AggregateRoot // DDD patterns comment: Add child entities and value-objects through the Order Aggregate-Root // methods and constructor so validations, invariants and business logic // make sure that consistency is preserved across the whole aggregate var address = new Address(message.Street, message.City, message.State, message.Country, message.ZipCode); var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber, message.CardHolderName, message.CardExpiration); foreach (var item in message.OrderItems) { order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units); } _logger.LogInformation("Creating Order - Order: {@Order}", order); _orderRepository.Add(order); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class CreateOrderIdentifiedCommandHandler : IdentifiedCommandHandler { public CreateOrderIdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for creating order. } } ================================================ FILE: src/Ordering.API/Application/Commands/CreateOrderDraftCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; using eShop.Ordering.API.Application.Models; public record CreateOrderDraftCommand(string BuyerId, IEnumerable Items) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/CreateOrderDraftCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; using eShop.Ordering.API.Extensions; using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; // Regular CommandHandler public class CreateOrderDraftCommandHandler : IRequestHandler { public Task Handle(CreateOrderDraftCommand message, CancellationToken cancellationToken) { var order = Order.NewDraft(); var orderItems = message.Items.Select(i => i.ToOrderItemDTO()); foreach (var item in orderItems) { order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units); } return Task.FromResult(OrderDraftDTO.FromOrder(order)); } } public record OrderDraftDTO { public IEnumerable OrderItems { get; init; } public decimal Total { get; init; } public static OrderDraftDTO FromOrder(Order order) { return new OrderDraftDTO() { OrderItems = order.OrderItems.Select(oi => new OrderItemDTO { Discount = oi.Discount, ProductId = oi.ProductId, UnitPrice = oi.UnitPrice, PictureUrl = oi.PictureUrl, Units = oi.Units, ProductName = oi.ProductName }), Total = order.GetTotal() }; } } public record OrderItemDTO { public int ProductId { get; init; } public string ProductName { get; init; } public decimal UnitPrice { get; init; } public decimal Discount { get; init; } public int Units { get; init; } public string PictureUrl { get; init; } } ================================================ FILE: src/Ordering.API/Application/Commands/IdentifiedCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public class IdentifiedCommand : IRequest where T : IRequest { public T Command { get; } public Guid Id { get; } public IdentifiedCommand(T command, Guid id) { Command = command; Id = id; } } ================================================ FILE: src/Ordering.API/Application/Commands/IdentifiedCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; /// /// Provides a base implementation for handling duplicate request and ensuring idempotent updates, in the cases where /// a requestid sent by client is used to detect duplicate requests. /// /// Type of the command handler that performs the operation if request is not duplicated /// Return value of the inner command handler public abstract class IdentifiedCommandHandler : IRequestHandler, R> where T : IRequest { private readonly IMediator _mediator; private readonly IRequestManager _requestManager; private readonly ILogger> _logger; public IdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) { ArgumentNullException.ThrowIfNull(logger); _mediator = mediator; _requestManager = requestManager; _logger = logger; } /// /// Creates the result value to return if a previous request was found /// /// protected abstract R CreateResultForDuplicateRequest(); /// /// This method handles the command. It just ensures that no other request exists with the same ID, and if this is the case /// just enqueues the original inner command. /// /// IdentifiedCommand which contains both original command & request ID /// Return value of inner command or default value if request same ID was found public async Task Handle(IdentifiedCommand message, CancellationToken cancellationToken) { var alreadyExists = await _requestManager.ExistAsync(message.Id); if (alreadyExists) { return CreateResultForDuplicateRequest(); } else { await _requestManager.CreateRequestForCommandAsync(message.Id); try { var command = message.Command; var commandName = command.GetGenericTypeName(); var idProperty = string.Empty; var commandId = string.Empty; switch (command) { case CreateOrderCommand createOrderCommand: idProperty = nameof(createOrderCommand.UserId); commandId = createOrderCommand.UserId; break; case CancelOrderCommand cancelOrderCommand: idProperty = nameof(cancelOrderCommand.OrderNumber); commandId = $"{cancelOrderCommand.OrderNumber}"; break; case ShipOrderCommand shipOrderCommand: idProperty = nameof(shipOrderCommand.OrderNumber); commandId = $"{shipOrderCommand.OrderNumber}"; break; default: idProperty = "Id?"; commandId = "n/a"; break; } _logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", commandName, idProperty, commandId, command); // Send the embedded business command to mediator so it runs its related CommandHandler var result = await _mediator.Send(command, cancellationToken); _logger.LogInformation( "Command result: {@Result} - {CommandName} - {IdProperty}: {CommandId} ({@Command})", result, commandName, idProperty, commandId, command); return result; } catch { return default; } } } } ================================================ FILE: src/Ordering.API/Application/Commands/SetAwaitingValidationOrderStatusCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record SetAwaitingValidationOrderStatusCommand(int OrderNumber) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/SetAwaitingValidationOrderStatusCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class SetAwaitingValidationOrderStatusCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public SetAwaitingValidationOrderStatusCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// graceperiod has finished /// /// /// public async Task Handle(SetAwaitingValidationOrderStatusCommand command, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetAwaitingValidationStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class SetAwaitingValidationIdentifiedOrderStatusCommandHandler : IdentifiedCommandHandler { public SetAwaitingValidationIdentifiedOrderStatusCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/Commands/SetPaidOrderStatusCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record SetPaidOrderStatusCommand(int OrderNumber) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/SetPaidOrderStatusCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class SetPaidOrderStatusCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public SetPaidOrderStatusCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// Shipment service confirms the payment /// /// /// public async Task Handle(SetPaidOrderStatusCommand command, CancellationToken cancellationToken) { // Simulate a work time for validating the payment await Task.Delay(10000, cancellationToken); var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetPaidStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class SetPaidIdentifiedOrderStatusCommandHandler : IdentifiedCommandHandler { public SetPaidIdentifiedOrderStatusCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/Commands/SetStockConfirmedOrderStatusCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record SetStockConfirmedOrderStatusCommand(int OrderNumber) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/SetStockConfirmedOrderStatusCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class SetStockConfirmedOrderStatusCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public SetStockConfirmedOrderStatusCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// Stock service confirms the request /// /// /// public async Task Handle(SetStockConfirmedOrderStatusCommand command, CancellationToken cancellationToken) { // Simulate a work time for confirming the stock await Task.Delay(10000, cancellationToken); var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetStockConfirmedStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class SetStockConfirmedOrderStatusIdentifiedCommandHandler : IdentifiedCommandHandler { public SetStockConfirmedOrderStatusIdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/Commands/SetStockRejectedOrderStatusCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record SetStockRejectedOrderStatusCommand(int OrderNumber, List OrderStockItems) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/SetStockRejectedOrderStatusCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class SetStockRejectedOrderStatusCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public SetStockRejectedOrderStatusCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// Stock service rejects the request /// /// /// public async Task Handle(SetStockRejectedOrderStatusCommand command, CancellationToken cancellationToken) { // Simulate a work time for rejecting the stock await Task.Delay(10000, cancellationToken); var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetCancelledStatusWhenStockIsRejected(command.OrderStockItems); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class SetStockRejectedOrderStatusIdentifiedCommandHandler : IdentifiedCommandHandler { public SetStockRejectedOrderStatusIdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/Commands/ShipOrderCommand.cs ================================================ namespace eShop.Ordering.API.Application.Commands; public record ShipOrderCommand(int OrderNumber) : IRequest; ================================================ FILE: src/Ordering.API/Application/Commands/ShipOrderCommandHandler.cs ================================================ namespace eShop.Ordering.API.Application.Commands; // Regular CommandHandler public class ShipOrderCommandHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public ShipOrderCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } /// /// Handler which processes the command when /// administrator executes ship order from app /// /// /// public async Task Handle(ShipOrderCommand command, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if (orderToUpdate == null) { return false; } orderToUpdate.SetShippedStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } // Use for Idempotency in Command process public class ShipOrderIdentifiedCommandHandler : IdentifiedCommandHandler { public ShipOrderIdentifiedCommandHandler( IMediator mediator, IRequestManager requestManager, ILogger> logger) : base(mediator, requestManager, logger) { } protected override bool CreateResultForDuplicateRequest() { return true; // Ignore duplicate requests for processing order. } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/OrderCancelledDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public partial class OrderCancelledDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly IBuyerRepository _buyerRepository; private readonly ILogger _logger; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public OrderCancelledDomainEventHandler( IOrderRepository orderRepository, ILogger logger, IBuyerRepository buyerRepository, IOrderingIntegrationEventService orderingIntegrationEventService) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); _orderingIntegrationEventService = orderingIntegrationEventService; } public async Task Handle(OrderCancelledDomainEvent domainEvent, CancellationToken cancellationToken) { OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.Order.Id, OrderStatus.Cancelled); var order = await _orderRepository.GetAsync(domainEvent.Order.Id); var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToCancelledIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/OrderShippedDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class OrderShippedDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly IBuyerRepository _buyerRepository; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; private readonly ILogger _logger; public OrderShippedDomainEventHandler( IOrderRepository orderRepository, ILogger logger, IBuyerRepository buyerRepository, IOrderingIntegrationEventService orderingIntegrationEventService) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); _orderingIntegrationEventService = orderingIntegrationEventService; } public async Task Handle(OrderShippedDomainEvent domainEvent, CancellationToken cancellationToken) { OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.Order.Id, OrderStatus.Shipped); var order = await _orderRepository.GetAsync(domainEvent.Order.Id); var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToShippedIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToAwaitingValidationDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class OrderStatusChangedToAwaitingValidationDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly ILogger _logger; private readonly IBuyerRepository _buyerRepository; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public OrderStatusChangedToAwaitingValidationDomainEventHandler( IOrderRepository orderRepository, ILogger logger, IBuyerRepository buyerRepository, IOrderingIntegrationEventService orderingIntegrationEventService) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _buyerRepository = buyerRepository; _orderingIntegrationEventService = orderingIntegrationEventService; } public async Task Handle(OrderStatusChangedToAwaitingValidationDomainEvent domainEvent, CancellationToken cancellationToken) { OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.AwaitingValidation); var order = await _orderRepository.GetAsync(domainEvent.OrderId); var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var orderStockList = domainEvent.OrderItems .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.Units)); var integrationEvent = new OrderStatusChangedToAwaitingValidationIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid, orderStockList); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToPaidDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class OrderStatusChangedToPaidDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly ILogger _logger; private readonly IBuyerRepository _buyerRepository; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public OrderStatusChangedToPaidDomainEventHandler( IOrderRepository orderRepository, ILogger logger, IBuyerRepository buyerRepository, IOrderingIntegrationEventService orderingIntegrationEventService) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService)); } public async Task Handle(OrderStatusChangedToPaidDomainEvent domainEvent, CancellationToken cancellationToken) { OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.Paid); var order = await _orderRepository.GetAsync(domainEvent.OrderId); var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var orderStockList = domainEvent.OrderItems .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.Units)); var integrationEvent = new OrderStatusChangedToPaidIntegrationEvent( domainEvent.OrderId, order.OrderStatus, buyer.Name, buyer.IdentityGuid, orderStockList); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToStockConfirmedDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class OrderStatusChangedToStockConfirmedDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly IBuyerRepository _buyerRepository; private readonly ILogger _logger; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public OrderStatusChangedToStockConfirmedDomainEventHandler( IOrderRepository orderRepository, IBuyerRepository buyerRepository, ILogger logger, IOrderingIntegrationEventService orderingIntegrationEventService) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _orderingIntegrationEventService = orderingIntegrationEventService; } public async Task Handle(OrderStatusChangedToStockConfirmedDomainEvent domainEvent, CancellationToken cancellationToken) { OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.StockConfirmed); var order = await _orderRepository.GetAsync(domainEvent.OrderId); var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToStockConfirmedIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler : INotificationHandler { private readonly IOrderRepository _orderRepository; private readonly ILogger _logger; public UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler( IOrderRepository orderRepository, ILogger logger) { _orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } // Domain Logic comment: // When the Buyer and Buyer's payment method have been created or verified that they existed, // then we can update the original Order with the BuyerId and PaymentId (foreign keys) public async Task Handle(BuyerAndPaymentMethodVerifiedDomainEvent domainEvent, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(domainEvent.OrderId); orderToUpdate.SetPaymentMethodVerified(domainEvent.Buyer.Id, domainEvent.Payment.Id); OrderingApiTrace.LogOrderPaymentMethodUpdated(_logger, domainEvent.OrderId, nameof(domainEvent.Payment), domainEvent.Payment.Id); } } ================================================ FILE: src/Ordering.API/Application/DomainEventHandlers/ValidateOrAddBuyerAggregateWhenOrderStartedDomainEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.DomainEventHandlers; public class ValidateOrAddBuyerAggregateWhenOrderStartedDomainEventHandler : INotificationHandler { private readonly ILogger _logger; private readonly IBuyerRepository _buyerRepository; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public ValidateOrAddBuyerAggregateWhenOrderStartedDomainEventHandler( ILogger logger, IBuyerRepository buyerRepository, IOrderingIntegrationEventService orderingIntegrationEventService) { _buyerRepository = buyerRepository ?? throw new ArgumentNullException(nameof(buyerRepository)); _orderingIntegrationEventService = orderingIntegrationEventService ?? throw new ArgumentNullException(nameof(orderingIntegrationEventService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task Handle(OrderStartedDomainEvent domainEvent, CancellationToken cancellationToken) { var cardTypeId = domainEvent.CardTypeId != 0 ? domainEvent.CardTypeId : 1; var buyer = await _buyerRepository.FindAsync(domainEvent.UserId); var buyerExisted = buyer is not null; if (!buyerExisted) { buyer = new Buyer(domainEvent.UserId, domainEvent.UserName); } // REVIEW: The event this creates needs to be sent after SaveChanges has propagated the buyer Id. It currently only // works by coincidence. If we remove HiLo or if anything decides to yield earlier, it will break. buyer.VerifyOrAddPaymentMethod(cardTypeId, $"Payment Method on {DateTime.UtcNow}", domainEvent.CardNumber, domainEvent.CardSecurityNumber, domainEvent.CardHolderName, domainEvent.CardExpiration, domainEvent.Order.Id); if (!buyerExisted) { _buyerRepository.Add(buyer); } await _buyerRepository.UnitOfWork .SaveEntitiesAsync(cancellationToken); var integrationEvent = new OrderStatusChangedToSubmittedIntegrationEvent(domainEvent.Order.Id, domainEvent.Order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); OrderingApiTrace.LogOrderBuyerAndPaymentValidatedOrUpdated(_logger, buyer.Id, domainEvent.Order.Id); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/EventHandling/GracePeriodConfirmedIntegrationEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.EventHandling; public class GracePeriodConfirmedIntegrationEventHandler( IMediator mediator, ILogger logger) : IIntegrationEventHandler { /// /// Event handler which confirms that the grace period /// has been completed and order will not initially be cancelled. /// Therefore, the order process continues for validation. /// /// /// /// public async Task Handle(GracePeriodConfirmedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var command = new SetAwaitingValidationOrderStatusCommand(@event.OrderId); logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.OrderNumber), command.OrderNumber, command); await mediator.Send(command); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/EventHandling/OrderPaymentFailedIntegrationEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.EventHandling; public class OrderPaymentFailedIntegrationEventHandler( IMediator mediator, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderPaymentFailedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var command = new CancelOrderCommand(@event.OrderId); logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.OrderNumber), command.OrderNumber, command); await mediator.Send(command); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/EventHandling/OrderPaymentSucceededIntegrationEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.EventHandling; public class OrderPaymentSucceededIntegrationEventHandler( IMediator mediator, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderPaymentSucceededIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var command = new SetPaidOrderStatusCommand(@event.OrderId); logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.OrderNumber), command.OrderNumber, command); await mediator.Send(command); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/EventHandling/OrderStockConfirmedIntegrationEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.EventHandling; public class OrderStockConfirmedIntegrationEventHandler( IMediator mediator, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStockConfirmedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var command = new SetStockConfirmedOrderStatusCommand(@event.OrderId); logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.OrderNumber), command.OrderNumber, command); await mediator.Send(command); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/EventHandling/OrderStockRejectedIntegrationEventHandler.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.EventHandling; public class OrderStockRejectedIntegrationEventHandler( IMediator mediator, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStockRejectedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); var orderStockRejectedItems = @event.OrderStockItems .FindAll(c => !c.HasStock) .Select(c => c.ProductId) .ToList(); var command = new SetStockRejectedOrderStatusCommand(@event.OrderId, orderStockRejectedItems); logger.LogInformation( "Sending command: {CommandName} - {IdProperty}: {CommandId} ({@Command})", command.GetGenericTypeName(), nameof(command.OrderNumber), command.OrderNumber, command); await mediator.Send(command); } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/GracePeriodConfirmedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record GracePeriodConfirmedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public GracePeriodConfirmedIntegrationEvent(int orderId) => OrderId = orderId; } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderPaymentFailedIntegrationEvent .cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderPaymentFailedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderPaymentFailedIntegrationEvent(int orderId) => OrderId = orderId; } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderPaymentSucceededIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderPaymentSucceededIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderPaymentSucceededIntegrationEvent(int orderId) => OrderId = orderId; } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStartedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; // Integration Events notes: // An Event is “something that has happened in the past”, therefore its name has to be // An Integration Event is an event that can cause side effects to other microservices, Bounded-Contexts or external systems. public record OrderStartedIntegrationEvent : IntegrationEvent { public string UserId { get; init; } public OrderStartedIntegrationEvent(string userId) => UserId = userId; } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToAwaitingValidationIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToAwaitingValidationIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public IEnumerable OrderStockItems { get; } public OrderStatusChangedToAwaitingValidationIntegrationEvent( int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid, IEnumerable orderStockItems) { OrderId = orderId; OrderStockItems = orderStockItems; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } public record OrderStockItem { public int ProductId { get; } public int Units { get; } public OrderStockItem(int productId, int units) { ProductId = productId; Units = units; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToCancelledIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToCancelledIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToCancelledIntegrationEvent (int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToPaidIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToPaidIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public IEnumerable OrderStockItems { get; } public OrderStatusChangedToPaidIntegrationEvent(int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid, IEnumerable orderStockItems) { OrderId = orderId; OrderStockItems = orderStockItems; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToShippedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToShippedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToShippedIntegrationEvent( int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedToStockConfirmedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToStockConfirmedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToStockConfirmedIntegrationEvent( int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStatusChangedTosubmittedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStatusChangedToSubmittedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStatus OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToSubmittedIntegrationEvent( int orderId, OrderStatus orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStockConfirmedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStockConfirmedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public OrderStockConfirmedIntegrationEvent(int orderId) => OrderId = orderId; } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/Events/OrderStockRejectedIntegrationEvent.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents.Events; public record OrderStockRejectedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public List OrderStockItems { get; } public OrderStockRejectedIntegrationEvent(int orderId, List orderStockItems) { OrderId = orderId; OrderStockItems = orderStockItems; } } public record ConfirmedOrderStockItem { public int ProductId { get; } public bool HasStock { get; } public ConfirmedOrderStockItem(int productId, bool hasStock) { ProductId = productId; HasStock = hasStock; } } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/IOrderingIntegrationEventService.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents; public interface IOrderingIntegrationEventService { Task PublishEventsThroughEventBusAsync(Guid transactionId); Task AddAndSaveEventAsync(IntegrationEvent evt); } ================================================ FILE: src/Ordering.API/Application/IntegrationEvents/OrderingIntegrationEventService.cs ================================================ namespace eShop.Ordering.API.Application.IntegrationEvents; public class OrderingIntegrationEventService(IEventBus eventBus, OrderingContext orderingContext, IIntegrationEventLogService integrationEventLogService, ILogger logger) : IOrderingIntegrationEventService { private readonly IEventBus _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); private readonly OrderingContext _orderingContext = orderingContext ?? throw new ArgumentNullException(nameof(orderingContext)); private readonly IIntegrationEventLogService _eventLogService = integrationEventLogService ?? throw new ArgumentNullException(nameof(integrationEventLogService)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); public async Task PublishEventsThroughEventBusAsync(Guid transactionId) { var pendingLogEvents = await _eventLogService.RetrieveEventLogsPendingToPublishAsync(transactionId); foreach (var logEvt in pendingLogEvents) { _logger.LogInformation("Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", logEvt.EventId, logEvt.IntegrationEvent); try { await _eventLogService.MarkEventAsInProgressAsync(logEvt.EventId); await _eventBus.PublishAsync(logEvt.IntegrationEvent); await _eventLogService.MarkEventAsPublishedAsync(logEvt.EventId); } catch (Exception ex) { _logger.LogError(ex, "Error publishing integration event: {IntegrationEventId}", logEvt.EventId); await _eventLogService.MarkEventAsFailedAsync(logEvt.EventId); } } } public async Task AddAndSaveEventAsync(IntegrationEvent evt) { _logger.LogInformation("Enqueuing integration event {IntegrationEventId} to repository ({@IntegrationEvent})", evt.Id, evt); await _eventLogService.SaveEventAsync(evt, _orderingContext.GetCurrentTransaction()); } } ================================================ FILE: src/Ordering.API/Application/Models/BasketItem.cs ================================================ namespace eShop.Ordering.API.Application.Models; public class BasketItem { public string Id { get; init; } public int ProductId { get; init; } public string ProductName { get; init; } public decimal UnitPrice { get; init; } public decimal OldUnitPrice { get; init; } public int Quantity { get; init; } public string PictureUrl { get; init; } } ================================================ FILE: src/Ordering.API/Application/Models/CustomerBasket.cs ================================================ namespace eShop.Ordering.API.Application.Models; public class CustomerBasket { public string BuyerId { get; set; } public List Items { get; set; } public CustomerBasket(string buyerId, List items) { BuyerId = buyerId; Items = items; } } ================================================ FILE: src/Ordering.API/Application/Queries/IOrderQueries.cs ================================================ namespace eShop.Ordering.API.Application.Queries; public interface IOrderQueries { Task GetOrderAsync(int id); Task> GetOrdersFromUserAsync(string userId); Task> GetCardTypesAsync(); } ================================================ FILE: src/Ordering.API/Application/Queries/OrderQueries.cs ================================================ namespace eShop.Ordering.API.Application.Queries; public class OrderQueries(OrderingContext context) : IOrderQueries { public async Task GetOrderAsync(int id) { var order = await context.Orders .Include(o => o.OrderItems) .FirstOrDefaultAsync(o => o.Id == id); if (order is null) throw new KeyNotFoundException(); return new Order { OrderNumber = order.Id, Date = order.OrderDate, Description = order.Description, City = order.Address.City, Country = order.Address.Country, State = order.Address.State, Street = order.Address.Street, Zipcode = order.Address.ZipCode, Status = order.OrderStatus.ToString(), Total = order.GetTotal(), OrderItems = order.OrderItems.Select(oi => new Orderitem { ProductName = oi.ProductName, Units = oi.Units, UnitPrice = (double)oi.UnitPrice, PictureUrl = oi.PictureUrl }).ToList() }; } public async Task> GetOrdersFromUserAsync(string userId) { return await context.Orders .Where(o => o.Buyer.IdentityGuid == userId) .Select(o => new OrderSummary { OrderNumber = o.Id, Date = o.OrderDate, Status = o.OrderStatus.ToString(), Total =(double) o.OrderItems.Sum(oi => oi.UnitPrice* oi.Units) }) .ToListAsync(); } public async Task> GetCardTypesAsync() => await context.CardTypes.Select(c=> new CardType { Id = c.Id, Name = c.Name }).ToListAsync(); } ================================================ FILE: src/Ordering.API/Application/Queries/OrderViewModel.cs ================================================ namespace eShop.Ordering.API.Application.Queries; public record Orderitem { public string ProductName { get; init; } public int Units { get; init; } public double UnitPrice { get; init; } public string PictureUrl { get; init; } } public record Order { public int OrderNumber { get; init; } public DateTime Date { get; init; } public string Status { get; init; } public string Description { get; init; } public string Street { get; init; } public string City { get; init; } public string State { get; init; } public string Zipcode { get; init; } public string Country { get; init; } public List OrderItems { get; set; } public decimal Total { get; set; } } public record OrderSummary { public int OrderNumber { get; init; } public DateTime Date { get; init; } public string Status { get; init; } public double Total { get; init; } } public record CardType { public int Id { get; init; } public string Name { get; init; } } ================================================ FILE: src/Ordering.API/Application/Validations/CancelOrderCommandValidator.cs ================================================ namespace eShop.Ordering.API.Application.Validations; public class CancelOrderCommandValidator : AbstractValidator { public CancelOrderCommandValidator(ILogger logger) { RuleFor(order => order.OrderNumber).NotEmpty().WithMessage("No orderId found"); if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("INSTANCE CREATED - {ClassName}", GetType().Name); } } } ================================================ FILE: src/Ordering.API/Application/Validations/CreateOrderCommandValidator.cs ================================================ namespace eShop.Ordering.API.Application.Validations; public class CreateOrderCommandValidator : AbstractValidator { public CreateOrderCommandValidator(ILogger logger) { RuleFor(command => command.City).NotEmpty(); RuleFor(command => command.Street).NotEmpty(); RuleFor(command => command.State).NotEmpty(); RuleFor(command => command.Country).NotEmpty(); RuleFor(command => command.ZipCode).NotEmpty(); RuleFor(command => command.CardNumber).NotEmpty().Length(12, 19); RuleFor(command => command.CardHolderName).NotEmpty(); RuleFor(command => command.CardExpiration).NotEmpty().Must(BeValidExpirationDate).WithMessage("Please specify a valid card expiration date"); RuleFor(command => command.CardSecurityNumber).NotEmpty().Length(3); RuleFor(command => command.CardTypeId).NotEmpty(); RuleFor(command => command.OrderItems).Must(ContainOrderItems).WithMessage("No order items found"); if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("INSTANCE CREATED - {ClassName}", GetType().Name); } } private bool BeValidExpirationDate(DateTime dateTime) { return dateTime >= DateTime.UtcNow; } private bool ContainOrderItems(IEnumerable orderItems) { return orderItems.Any(); } } ================================================ FILE: src/Ordering.API/Application/Validations/IdentifiedCommandValidator.cs ================================================ namespace eShop.Ordering.API.Application.Validations; public class IdentifiedCommandValidator : AbstractValidator> { public IdentifiedCommandValidator(ILogger logger) { RuleFor(command => command.Id).NotEmpty(); if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("INSTANCE CREATED - {ClassName}", GetType().Name); } } } ================================================ FILE: src/Ordering.API/Application/Validations/ShipOrderCommandValidator.cs ================================================ namespace eShop.Ordering.API.Application.Validations; public class ShipOrderCommandValidator : AbstractValidator { public ShipOrderCommandValidator(ILogger logger) { RuleFor(order => order.OrderNumber).NotEmpty().WithMessage("No orderId found"); if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("INSTANCE CREATED - {ClassName}", GetType().Name); } } } ================================================ FILE: src/Ordering.API/Extensions/BasketItemExtensions.cs ================================================ namespace eShop.Ordering.API.Extensions; public static class BasketItemExtensions { public static IEnumerable ToOrderItemsDTO(this IEnumerable basketItems) { foreach (var item in basketItems) { yield return item.ToOrderItemDTO(); } } public static OrderItemDTO ToOrderItemDTO(this BasketItem item) { return new OrderItemDTO() { ProductId = item.ProductId, ProductName = item.ProductName, PictureUrl = item.PictureUrl, UnitPrice = item.UnitPrice, Units = item.Quantity }; } } ================================================ FILE: src/Ordering.API/Extensions/Extensions.cs ================================================ using FluentValidation; internal static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { var services = builder.Services; // Add the authentication services to DI builder.AddDefaultAuthentication(); // Pooling is disabled because of the following error: // Unhandled exception. System.InvalidOperationException: // The DbContext of type 'OrderingContext' cannot be pooled because it does not have a public constructor accepting a single parameter of type DbContextOptions or has more than one constructor. services.AddDbContext(options => { options.UseNpgsql(builder.Configuration.GetConnectionString("orderingdb")); }); builder.EnrichNpgsqlDbContext(); services.AddMigration(); // Add the integration services that consume the DbContext services.AddTransient>(); services.AddTransient(); builder.AddRabbitMqEventBus("eventbus") .AddEventBusSubscriptions(); services.AddHttpContextAccessor(); services.AddTransient(); // Configure mediatR services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblyContaining(typeof(Program)); cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); }); // Register the command validators for the validator behavior (validators based on FluentValidation library) services.AddValidatorsFromAssemblyContaining(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); } private static void AddEventBusSubscriptions(this IEventBusBuilder eventBus) { eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); } } ================================================ FILE: src/Ordering.API/Extensions/LinqSelectExtensions.cs ================================================ namespace eShop.Ordering.API.Extensions; public static class LinqSelectExtensions { public static IEnumerable> SelectTry(this IEnumerable enumerable, Func selector) { foreach (TSource element in enumerable) { SelectTryResult returnedValue; try { returnedValue = new SelectTryResult(element, selector(element), null); } catch (Exception ex) { returnedValue = new SelectTryResult(element, default, ex); } yield return returnedValue; } } public static IEnumerable OnCaughtException(this IEnumerable> enumerable, Func exceptionHandler) { return enumerable.Select(x => x.CaughtException == null ? x.Result : exceptionHandler(x.CaughtException)); } public static IEnumerable OnCaughtException(this IEnumerable> enumerable, Func exceptionHandler) { return enumerable.Select(x => x.CaughtException == null ? x.Result : exceptionHandler(x.Source, x.CaughtException)); } public class SelectTryResult { internal SelectTryResult(TSource source, TResult result, Exception exception) { Source = source; Result = result; CaughtException = exception; } public TSource Source { get; private set; } public TResult Result { get; private set; } public Exception CaughtException { get; private set; } } } ================================================ FILE: src/Ordering.API/Extensions/OrderingApiTrace.cs ================================================ namespace eShop.Ordering.API.Extensions; internal static partial class OrderingApiTrace { [LoggerMessage(EventId = 1, EventName = "OrderStatusUpdated", Level = LogLevel.Trace, Message = "Order with Id: {OrderId} has been successfully updated to status {Status}")] public static partial void LogOrderStatusUpdated(ILogger logger, int orderId, OrderStatus status); [LoggerMessage(EventId = 2, EventName = "PaymentMethodUpdated", Level = LogLevel.Trace, Message = "Order with Id: {OrderId} has been successfully updated with a payment method {PaymentMethod} ({Id})")] public static partial void LogOrderPaymentMethodUpdated(ILogger logger, int orderId, string paymentMethod, int id); [LoggerMessage(EventId = 3, EventName = "BuyerAndPaymentValidatedOrUpdated", Level = LogLevel.Trace, Message = "Buyer {BuyerId} and related payment method were validated or updated for order Id: {OrderId}.")] public static partial void LogOrderBuyerAndPaymentValidatedOrUpdated(ILogger logger, int buyerId, int orderId); } ================================================ FILE: src/Ordering.API/GlobalUsings.cs ================================================ global using Asp.Versioning.Conventions; global using System.Runtime.Serialization; global using FluentValidation; global using MediatR; global using Microsoft.AspNetCore.Mvc; global using Microsoft.EntityFrameworkCore; global using eShop.EventBus.Abstractions; global using eShop.EventBus.Events; global using eShop.EventBus.Extensions; global using eShop.IntegrationEventLogEF.Services; global using eShop.Ordering.API.Application.Behaviors; global using eShop.Ordering.API.Application.Commands; global using eShop.Ordering.API.Application.IntegrationEvents; global using eShop.Ordering.API.Application.IntegrationEvents.EventHandling; global using eShop.Ordering.API.Application.IntegrationEvents.Events; global using eShop.Ordering.API.Application.Models; global using eShop.Ordering.API.Application.Queries; global using eShop.Ordering.API.Application.Validations; global using eShop.Ordering.API.Extensions; global using eShop.Ordering.API.Infrastructure; global using eShop.Ordering.API.Infrastructure.Services; global using eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; global using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; global using eShop.Ordering.Domain.Events; global using eShop.Ordering.Domain.Exceptions; global using eShop.Ordering.Domain.SeedWork; global using eShop.Ordering.Infrastructure; global using eShop.Ordering.Infrastructure.Idempotency; global using eShop.Ordering.Infrastructure.Repositories; global using eShop.ServiceDefaults; ================================================ FILE: src/Ordering.API/Infrastructure/OrderingContextSeed.cs ================================================ namespace eShop.Ordering.API.Infrastructure; using eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; public class OrderingContextSeed: IDbSeeder { public async Task SeedAsync(OrderingContext context) { if (!context.CardTypes.Any()) { context.CardTypes.AddRange(GetPredefinedCardTypes()); await context.SaveChangesAsync(); } await context.SaveChangesAsync(); } private static IEnumerable GetPredefinedCardTypes() { yield return new CardType { Id = 1, Name = "Amex" }; yield return new CardType { Id = 2, Name = "Visa" }; yield return new CardType { Id = 3, Name = "MasterCard" }; } } ================================================ FILE: src/Ordering.API/Infrastructure/Services/IIdentityService.cs ================================================ namespace eShop.Ordering.API.Infrastructure.Services; public interface IIdentityService { string GetUserIdentity(); string GetUserName(); } ================================================ FILE: src/Ordering.API/Infrastructure/Services/IdentityService.cs ================================================ namespace eShop.Ordering.API.Infrastructure.Services; public class IdentityService(IHttpContextAccessor context) : IIdentityService { public string GetUserIdentity() => context.HttpContext?.User.FindFirst("sub")?.Value; public string GetUserName() => context.HttpContext?.User.Identity?.Name; } ================================================ FILE: src/Ordering.API/Ordering.API.csproj ================================================  net10.0 eShop.Ordering.API 7161b768-033d-41c7-bc5d-37528275e1f3 all runtime; build; native; contentfiles; analyzers; buildtransitive PreserveNewest true PreserveNewest ================================================ FILE: src/Ordering.API/Program.Testing.cs ================================================ // Require a public Program class to implement the // fixture for the WebApplicationFactory in the // integration tests. Using IVT is not sufficient // in this case, because the accessibility of the // `Program` type is checked. public partial class Program { } ================================================ FILE: src/Ordering.API/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddApplicationServices(); builder.Services.AddProblemDetails(); var withApiVersioning = builder.Services.AddApiVersioning(); builder.AddDefaultOpenApi(withApiVersioning); var app = builder.Build(); app.MapDefaultEndpoints(); var orders = app.NewVersionedApi("Orders"); orders.MapOrdersApiV1() .RequireAuthorization(); app.UseDefaultOpenApi(); app.Run(); ================================================ FILE: src/Ordering.API/Properties/launchSettings.json ================================================ { "profiles": { "http": { "commandName": "Project", "launchBrowser": true, "applicationUrl": "http://localhost:5224/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/Ordering.API/appsettings.Development.json ================================================ { "ConnectionStrings": { "OrderingDB": "Host=localhost;Database=OrderingDB;Username=postgres;Password=yourWeak(!)Password" } } ================================================ FILE: src/Ordering.API/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "OpenApi": { "Endpoint": { "Name": "Ordering.API V1" }, "Document": { "Description": "The Ordering Service HTTP API", "Title": "eShop - Ordering HTTP API", "Version": "v1" }, "Auth": { "ClientId": "orderingswaggerui", "AppName": "Ordering Swagger UI" } }, "ConnectionStrings": { "EventBus": "amqp://localhost" }, "EventBus": { "SubscriptionClientName": "Ordering" }, "Identity": { "Audience": "orders", "Scopes": { "orders": "Ordering API" } } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/BuyerAggregate/Buyer.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; public class Buyer : Entity, IAggregateRoot { [Required] public string IdentityGuid { get; private set; } public string Name { get; private set; } private List _paymentMethods; public IEnumerable PaymentMethods => _paymentMethods.AsReadOnly(); protected Buyer() { _paymentMethods = new List(); } public Buyer(string identity, string name) : this() { IdentityGuid = !string.IsNullOrWhiteSpace(identity) ? identity : throw new ArgumentNullException(nameof(identity)); Name = !string.IsNullOrWhiteSpace(name) ? name : throw new ArgumentNullException(nameof(name)); } public PaymentMethod VerifyOrAddPaymentMethod( int cardTypeId, string alias, string cardNumber, string securityNumber, string cardHolderName, DateTime expiration, int orderId) { var existingPayment = _paymentMethods .SingleOrDefault(p => p.IsEqualTo(cardTypeId, cardNumber, expiration)); if (existingPayment != null) { AddDomainEvent(new BuyerAndPaymentMethodVerifiedDomainEvent(this, existingPayment, orderId)); return existingPayment; } var payment = new PaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration); _paymentMethods.Add(payment); AddDomainEvent(new BuyerAndPaymentMethodVerifiedDomainEvent(this, payment, orderId)); return payment; } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/BuyerAggregate/CardType.cs ================================================ namespace eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; public sealed class CardType { public int Id { get; init; } public required string Name { get; init; } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/BuyerAggregate/IBuyerRepository.cs ================================================ namespace eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; //This is just the RepositoryContracts or Interface defined at the Domain Layer //as requisite for the Buyer Aggregate public interface IBuyerRepository : IRepository { Buyer Add(Buyer buyer); Buyer Update(Buyer buyer); Task FindAsync(string BuyerIdentityGuid); Task FindByIdAsync(int id); } ================================================ FILE: src/Ordering.Domain/AggregatesModel/BuyerAggregate/PaymentMethod.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; public class PaymentMethod : Entity { [Required] private string _alias; [Required] private string _cardNumber; private string _securityNumber; [Required] private string _cardHolderName; private DateTime _expiration; private int _cardTypeId; public CardType CardType { get; private set; } protected PaymentMethod() { } public PaymentMethod(int cardTypeId, string alias, string cardNumber, string securityNumber, string cardHolderName, DateTime expiration) { _cardNumber = !string.IsNullOrWhiteSpace(cardNumber) ? cardNumber : throw new OrderingDomainException(nameof(cardNumber)); _securityNumber = !string.IsNullOrWhiteSpace(securityNumber) ? securityNumber : throw new OrderingDomainException(nameof(securityNumber)); _cardHolderName = !string.IsNullOrWhiteSpace(cardHolderName) ? cardHolderName : throw new OrderingDomainException(nameof(cardHolderName)); if (expiration < DateTime.UtcNow) { throw new OrderingDomainException(nameof(expiration)); } _alias = alias; _expiration = expiration; _cardTypeId = cardTypeId; } public bool IsEqualTo(int cardTypeId, string cardNumber, DateTime expiration) { return _cardTypeId == cardTypeId && _cardNumber == cardNumber && _expiration == expiration; } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/OrderAggregate/Address.cs ================================================ using eShop.Ordering.Domain.SeedWork; namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; public class Address : ValueObject { public string Street { get; private set; } public string City { get; private set; } public string State { get; private set; } public string Country { get; private set; } public string ZipCode { get; private set; } public Address() { } public Address(string street, string city, string state, string country, string zipcode) { Street = street; City = city; State = state; Country = country; ZipCode = zipcode; } protected override IEnumerable GetEqualityComponents() { // Using a yield return statement to return each element one at a time yield return Street; yield return City; yield return State; yield return Country; yield return ZipCode; } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/OrderAggregate/IOrderRepository.cs ================================================ namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; //This is just the RepositoryContracts or Interface defined at the Domain Layer //as requisite for the Order Aggregate public interface IOrderRepository : IRepository { Order Add(Order order); void Update(Order order); Task GetAsync(int orderId); } ================================================ FILE: src/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; public class Order : Entity, IAggregateRoot { public DateTime OrderDate { get; private set; } // Address is a Value Object pattern example persisted as EF Core 2.0 owned entity [Required] public Address Address { get; private set; } public int? BuyerId { get; private set; } public Buyer Buyer { get; } public OrderStatus OrderStatus { get; private set; } public string Description { get; private set; } // Draft orders have this set to true. Currently we don't check anywhere the draft status of an Order, but we could do it if needed #pragma warning disable CS0414 // The field 'Order._isDraft' is assigned but its value is never used private bool _isDraft; #pragma warning restore CS0414 // DDD Patterns comment // Using a private collection field, better for DDD Aggregate's encapsulation // so OrderItems cannot be added from "outside the AggregateRoot" directly to the collection, // but only through the method OrderAggregateRoot.AddOrderItem() which includes behavior. private readonly List _orderItems; public IReadOnlyCollection OrderItems => _orderItems.AsReadOnly(); public int? PaymentId { get; private set; } public static Order NewDraft() { var order = new Order { _isDraft = true }; return order; } protected Order() { _orderItems = new List(); _isDraft = false; } public Order(string userId, string userName, Address address, int cardTypeId, string cardNumber, string cardSecurityNumber, string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null) : this() { BuyerId = buyerId; PaymentId = paymentMethodId; OrderStatus = OrderStatus.Submitted; OrderDate = DateTime.UtcNow; Address = address; // Add the OrderStarterDomainEvent to the domain events collection // to be raised/dispatched when committing changes into the Database [ After DbContext.SaveChanges() ] AddOrderStartedDomainEvent(userId, userName, cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); } // DDD Patterns comment // This Order AggregateRoot's method "AddOrderItem()" should be the only way to add Items to the Order, // so any behavior (discounts, etc.) and validations are controlled by the AggregateRoot // in order to maintain consistency between the whole Aggregate. public void AddOrderItem(int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1) { var existingOrderForProduct = _orderItems.SingleOrDefault(o => o.ProductId == productId); if (existingOrderForProduct != null) { //if previous line exist modify it with higher discount and units.. if (discount > existingOrderForProduct.Discount) { existingOrderForProduct.SetNewDiscount(discount); } existingOrderForProduct.AddUnits(units); } else { //add validated new order item var orderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units); _orderItems.Add(orderItem); } } public void SetPaymentMethodVerified(int buyerId, int paymentId) { BuyerId = buyerId; PaymentId = paymentId; } public void SetAwaitingValidationStatus() { if (OrderStatus == OrderStatus.Submitted) { AddDomainEvent(new OrderStatusChangedToAwaitingValidationDomainEvent(Id, _orderItems)); OrderStatus = OrderStatus.AwaitingValidation; } } public void SetStockConfirmedStatus() { if (OrderStatus == OrderStatus.AwaitingValidation) { AddDomainEvent(new OrderStatusChangedToStockConfirmedDomainEvent(Id)); OrderStatus = OrderStatus.StockConfirmed; Description = "All the items were confirmed with available stock."; } } public void SetPaidStatus() { if (OrderStatus == OrderStatus.StockConfirmed) { AddDomainEvent(new OrderStatusChangedToPaidDomainEvent(Id, OrderItems)); OrderStatus = OrderStatus.Paid; Description = "The payment was performed at a simulated \"American Bank checking bank account ending on XX35071\""; } } public void SetShippedStatus() { if (OrderStatus != OrderStatus.Paid) { StatusChangeException(OrderStatus.Shipped); } OrderStatus = OrderStatus.Shipped; Description = "The order was shipped."; AddDomainEvent(new OrderShippedDomainEvent(this)); } public void SetCancelledStatus() { if (OrderStatus == OrderStatus.Paid || OrderStatus == OrderStatus.Shipped) { StatusChangeException(OrderStatus.Cancelled); } OrderStatus = OrderStatus.Cancelled; Description = "The order was cancelled."; AddDomainEvent(new OrderCancelledDomainEvent(this)); } public void SetCancelledStatusWhenStockIsRejected(IEnumerable orderStockRejectedItems) { if (OrderStatus == OrderStatus.AwaitingValidation) { OrderStatus = OrderStatus.Cancelled; var itemsStockRejectedProductNames = OrderItems .Where(c => orderStockRejectedItems.Contains(c.ProductId)) .Select(c => c.ProductName); var itemsStockRejectedDescription = string.Join(", ", itemsStockRejectedProductNames); Description = $"The product items don't have stock: ({itemsStockRejectedDescription})."; } } private void AddOrderStartedDomainEvent(string userId, string userName, int cardTypeId, string cardNumber, string cardSecurityNumber, string cardHolderName, DateTime cardExpiration) { var orderStartedDomainEvent = new OrderStartedDomainEvent(this, userId, userName, cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); this.AddDomainEvent(orderStartedDomainEvent); } private void StatusChangeException(OrderStatus orderStatusToChange) { throw new OrderingDomainException($"Is not possible to change the order status from {OrderStatus} to {orderStatusToChange}."); } public decimal GetTotal() => _orderItems.Sum(o => o.Units * o.UnitPrice); } ================================================ FILE: src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderItem.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; public class OrderItem : Entity { [Required] public string ProductName { get; private set; } public string PictureUrl { get; private set;} public decimal UnitPrice { get; private set;} public decimal Discount { get; private set; } public int Units { get; private set; } public int ProductId { get; private set; } protected OrderItem() { } public OrderItem(int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1) { if (units <= 0) { throw new OrderingDomainException("Invalid number of units"); } if ((unitPrice * units) < discount) { throw new OrderingDomainException("The total of order item is lower than applied discount"); } ProductId = productId; ProductName = productName; UnitPrice = unitPrice; Discount = discount; Units = units; PictureUrl = pictureUrl; } public void SetNewDiscount(decimal discount) { if (discount < 0) { throw new OrderingDomainException("Discount is not valid"); } Discount = discount; } public void AddUnits(int units) { if (units < 0) { throw new OrderingDomainException("Invalid units"); } Units += units; } } ================================================ FILE: src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderStatus.cs ================================================ using System.Text.Json.Serialization; namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; [JsonConverter(typeof(JsonStringEnumConverter))] public enum OrderStatus { Submitted = 1, AwaitingValidation = 2, StockConfirmed = 3, Paid = 4, Shipped = 5, Cancelled = 6 } ================================================ FILE: src/Ordering.Domain/Events/BuyerPaymentMethodVerifiedDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; public class BuyerAndPaymentMethodVerifiedDomainEvent : INotification { public Buyer Buyer { get; private set; } public PaymentMethod Payment { get; private set; } public int OrderId { get; private set; } public BuyerAndPaymentMethodVerifiedDomainEvent(Buyer buyer, PaymentMethod payment, int orderId) { Buyer = buyer; Payment = payment; OrderId = orderId; } } ================================================ FILE: src/Ordering.Domain/Events/OrderCancelledDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; public class OrderCancelledDomainEvent : INotification { public Order Order { get; } public OrderCancelledDomainEvent(Order order) { Order = order; } } ================================================ FILE: src/Ordering.Domain/Events/OrderShippedDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; public class OrderShippedDomainEvent : INotification { public Order Order { get; } public OrderShippedDomainEvent(Order order) { Order = order; } } ================================================ FILE: src/Ordering.Domain/Events/OrderStartedDomainEvent.cs ================================================  namespace eShop.Ordering.Domain.Events; /// /// Event used when an order is created /// public record class OrderStartedDomainEvent( Order Order, string UserId, string UserName, int CardTypeId, string CardNumber, string CardSecurityNumber, string CardHolderName, DateTime CardExpiration) : INotification; ================================================ FILE: src/Ordering.Domain/Events/OrderStatusChangedToAwaitingValidationDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; /// /// Event used when the grace period order is confirmed /// public class OrderStatusChangedToAwaitingValidationDomainEvent : INotification { public int OrderId { get; } public IEnumerable OrderItems { get; } public OrderStatusChangedToAwaitingValidationDomainEvent(int orderId, IEnumerable orderItems) { OrderId = orderId; OrderItems = orderItems; } } ================================================ FILE: src/Ordering.Domain/Events/OrderStatusChangedToPaidDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; /// /// Event used when the order is paid /// public class OrderStatusChangedToPaidDomainEvent : INotification { public int OrderId { get; } public IEnumerable OrderItems { get; } public OrderStatusChangedToPaidDomainEvent(int orderId, IEnumerable orderItems) { OrderId = orderId; OrderItems = orderItems; } } ================================================ FILE: src/Ordering.Domain/Events/OrderStatusChangedToStockConfirmedDomainEvent.cs ================================================ namespace eShop.Ordering.Domain.Events; /// /// Event used when the order stock items are confirmed /// public class OrderStatusChangedToStockConfirmedDomainEvent : INotification { public int OrderId { get; } public OrderStatusChangedToStockConfirmedDomainEvent(int orderId) => OrderId = orderId; } ================================================ FILE: src/Ordering.Domain/Exceptions/OrderingDomainException.cs ================================================ namespace eShop.Ordering.Domain.Exceptions; /// /// Exception type for domain exceptions /// public class OrderingDomainException : Exception { public OrderingDomainException() { } public OrderingDomainException(string message) : base(message) { } public OrderingDomainException(string message, Exception innerException) : base(message, innerException) { } } ================================================ FILE: src/Ordering.Domain/GlobalUsings.cs ================================================ global using System.Reflection; global using eShop.Ordering.Domain.Exceptions; global using MediatR; global using eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; global using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; global using eShop.Ordering.Domain.Events; global using eShop.Ordering.Domain.Seedwork; ================================================ FILE: src/Ordering.Domain/Ordering.Domain.csproj ================================================  net10.0 ================================================ FILE: src/Ordering.Domain/SeedWork/Entity.cs ================================================ namespace eShop.Ordering.Domain.Seedwork; public abstract class Entity { int? _requestedHashCode; int _Id; public virtual int Id { get { return _Id; } protected set { _Id = value; } } private List _domainEvents; public IReadOnlyCollection DomainEvents => _domainEvents?.AsReadOnly(); public void AddDomainEvent(INotification eventItem) { _domainEvents = _domainEvents ?? new List(); _domainEvents.Add(eventItem); } public void RemoveDomainEvent(INotification eventItem) { _domainEvents?.Remove(eventItem); } public void ClearDomainEvents() { _domainEvents?.Clear(); } public bool IsTransient() { return this.Id == default; } public override bool Equals(object obj) { if (obj == null || !(obj is Entity)) return false; if (Object.ReferenceEquals(this, obj)) return true; if (this.GetType() != obj.GetType()) return false; Entity item = (Entity)obj; if (item.IsTransient() || this.IsTransient()) return false; else return item.Id == this.Id; } public override int GetHashCode() { if (!IsTransient()) { if (!_requestedHashCode.HasValue) _requestedHashCode = this.Id.GetHashCode() ^ 31; // XOR for random distribution (http://blogs.msdn.com/b/ericlippert/archive/2011/02/28/guidelines-and-rules-for-gethashcode.aspx) return _requestedHashCode.Value; } else return base.GetHashCode(); } public static bool operator ==(Entity left, Entity right) { if (Object.Equals(left, null)) return (Object.Equals(right, null)) ? true : false; else return left.Equals(right); } public static bool operator !=(Entity left, Entity right) { return !(left == right); } } ================================================ FILE: src/Ordering.Domain/SeedWork/IAggregateRoot.cs ================================================ namespace eShop.Ordering.Domain.Seedwork; public interface IAggregateRoot { } ================================================ FILE: src/Ordering.Domain/SeedWork/IRepository.cs ================================================ namespace eShop.Ordering.Domain.Seedwork; public interface IRepository where T : IAggregateRoot { IUnitOfWork UnitOfWork { get; } } ================================================ FILE: src/Ordering.Domain/SeedWork/IUnitOfWork.cs ================================================ namespace eShop.Ordering.Domain.Seedwork; public interface IUnitOfWork : IDisposable { Task SaveChangesAsync(CancellationToken cancellationToken = default); Task SaveEntitiesAsync(CancellationToken cancellationToken = default); } ================================================ FILE: src/Ordering.Domain/SeedWork/ValueObject.cs ================================================ namespace eShop.Ordering.Domain.SeedWork; public abstract class ValueObject { protected static bool EqualOperator(ValueObject left, ValueObject right) { if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null)) { return false; } return ReferenceEquals(left, null) || left.Equals(right); } protected static bool NotEqualOperator(ValueObject left, ValueObject right) { return !(EqualOperator(left, right)); } protected abstract IEnumerable GetEqualityComponents(); public override bool Equals(object obj) { if (obj == null || obj.GetType() != GetType()) { return false; } var other = (ValueObject)obj; return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); } public override int GetHashCode() { return GetEqualityComponents() .Select(x => x != null ? x.GetHashCode() : 0) .Aggregate((x, y) => x ^ y); } public ValueObject GetCopy() { return this.MemberwiseClone() as ValueObject; } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/BuyerEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class BuyerEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder buyerConfiguration) { buyerConfiguration.ToTable("buyers"); buyerConfiguration.Ignore(b => b.DomainEvents); buyerConfiguration.Property(b => b.Id) .UseHiLo("buyerseq"); buyerConfiguration.Property(b => b.IdentityGuid) .HasMaxLength(200); buyerConfiguration.HasIndex("IdentityGuid") .IsUnique(true); buyerConfiguration.HasMany(b => b.PaymentMethods) .WithOne(); } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/CardTypeEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class CardTypeEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder cardTypesConfiguration) { cardTypesConfiguration.ToTable("cardtypes"); cardTypesConfiguration.Property(ct => ct.Id) .ValueGeneratedNever(); cardTypesConfiguration.Property(ct => ct.Name) .HasMaxLength(200) .IsRequired(); } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class ClientRequestEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder requestConfiguration) { requestConfiguration.ToTable("requests"); } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class OrderEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder orderConfiguration) { orderConfiguration.ToTable("orders"); orderConfiguration.Ignore(b => b.DomainEvents); orderConfiguration.Property(o => o.Id) .UseHiLo("orderseq"); //Address value object persisted as owned entity type supported since EF Core 2.0 orderConfiguration .OwnsOne(o => o.Address); orderConfiguration .Property(o => o.OrderStatus) .HasConversion() .HasMaxLength(30); orderConfiguration .Property(o => o.PaymentId) .HasColumnName("PaymentMethodId"); orderConfiguration.HasOne() .WithMany() .HasForeignKey(o => o.PaymentId) .OnDelete(DeleteBehavior.Restrict); orderConfiguration.HasOne(o => o.Buyer) .WithMany() .HasForeignKey(o => o.BuyerId); } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/OrderItemEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class OrderItemEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder orderItemConfiguration) { orderItemConfiguration.ToTable("orderItems"); orderItemConfiguration.Ignore(b => b.DomainEvents); orderItemConfiguration.Property(o => o.Id) .UseHiLo("orderitemseq"); orderItemConfiguration.Property("OrderId"); } } ================================================ FILE: src/Ordering.Infrastructure/EntityConfigurations/PaymentMethodEntityTypeConfiguration.cs ================================================ namespace eShop.Ordering.Infrastructure.EntityConfigurations; class PaymentMethodEntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder paymentConfiguration) { paymentConfiguration.ToTable("paymentmethods"); paymentConfiguration.Ignore(b => b.DomainEvents); paymentConfiguration.Property(b => b.Id) .UseHiLo("paymentseq"); paymentConfiguration.Property("BuyerId"); paymentConfiguration .Property("_cardHolderName") .HasColumnName("CardHolderName") .HasMaxLength(200); paymentConfiguration .Property("_alias") .HasColumnName("Alias") .HasMaxLength(200); paymentConfiguration .Property("_cardNumber") .HasColumnName("CardNumber") .HasMaxLength(25) .IsRequired(); paymentConfiguration .Property("_expiration") .HasColumnName("Expiration") .HasMaxLength(25); paymentConfiguration .Property("_cardTypeId") .HasColumnName("CardTypeId"); paymentConfiguration.HasOne(p => p.CardType) .WithMany() .HasForeignKey("_cardTypeId"); } } ================================================ FILE: src/Ordering.Infrastructure/GlobalUsings.cs ================================================ global using System.Data; global using MediatR; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Design; global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.EntityFrameworkCore.Storage; global using eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; global using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; global using eShop.Ordering.Domain.Exceptions; global using eShop.Ordering.Domain.Seedwork; global using eShop.Ordering.Infrastructure.EntityConfigurations; global using eShop.Ordering.Infrastructure.Idempotency; ================================================ FILE: src/Ordering.Infrastructure/Idempotency/ClientRequest.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.Infrastructure.Idempotency; public class ClientRequest { public Guid Id { get; set; } [Required] public string Name { get; set; } public DateTime Time { get; set; } } ================================================ FILE: src/Ordering.Infrastructure/Idempotency/IRequestManager.cs ================================================ namespace eShop.Ordering.Infrastructure.Idempotency; public interface IRequestManager { Task ExistAsync(Guid id); Task CreateRequestForCommandAsync(Guid id); } ================================================ FILE: src/Ordering.Infrastructure/Idempotency/RequestManager.cs ================================================ namespace eShop.Ordering.Infrastructure.Idempotency; public class RequestManager : IRequestManager { private readonly OrderingContext _context; public RequestManager(OrderingContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } public async Task ExistAsync(Guid id) { var request = await _context. FindAsync(id); return request != null; } public async Task CreateRequestForCommandAsync(Guid id) { var exists = await ExistAsync(id); var request = exists ? throw new OrderingDomainException($"Request with {id} already exists") : new ClientRequest() { Id = id, Name = typeof(T).Name, Time = DateTime.UtcNow }; _context.Add(request); await _context.SaveChangesAsync(); } } ================================================ FILE: src/Ordering.Infrastructure/MediatorExtension.cs ================================================ namespace eShop.Ordering.Infrastructure; static class MediatorExtension { public static async Task DispatchDomainEventsAsync(this IMediator mediator, OrderingContext ctx) { var domainEntities = ctx.ChangeTracker .Entries() .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any()); var domainEvents = domainEntities .SelectMany(x => x.Entity.DomainEvents) .ToList(); domainEntities.ToList() .ForEach(entity => entity.Entity.ClearDomainEvents()); foreach (var domainEvent in domainEvents) await mediator.Publish(domainEvent); } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20230925222426_Initial.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Ordering.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace Ordering.Infrastructure.Migrations { [DbContext(typeof(OrderingContext))] [Migration("20230925222426_Initial")] partial class Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rc.1.23419.6") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("buyerseq", "ordering") .IncrementsBy(10); modelBuilder.HasSequence("orderitemseq") .IncrementsBy(10); modelBuilder.HasSequence("orderseq", "ordering") .IncrementsBy(10); modelBuilder.HasSequence("paymentseq", "ordering") .IncrementsBy(10); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "buyerseq", "ordering"); b.Property("IdentityGuid") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("Name") .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("IdentityGuid") .IsUnique(); b.ToTable("buyers", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b => { b.Property("Id") .HasColumnType("integer") .HasDefaultValue(1); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("cardtypes", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "paymentseq", "ordering"); b.Property("BuyerId") .HasColumnType("integer"); b.Property("_alias") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("Alias"); b.Property("_cardHolderName") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("CardHolderName"); b.Property("_cardNumber") .IsRequired() .HasMaxLength(25) .HasColumnType("character varying(25)") .HasColumnName("CardNumber"); b.Property("_cardTypeId") .HasColumnType("integer") .HasColumnName("CardTypeId"); b.Property("_expiration") .HasMaxLength(25) .HasColumnType("timestamp with time zone") .HasColumnName("Expiration"); b.HasKey("Id"); b.HasIndex("BuyerId"); b.HasIndex("_cardTypeId"); b.ToTable("paymentmethods", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderseq", "ordering"); b.Property("Description") .HasColumnType("text"); b.Property("_buyerId") .HasColumnType("integer") .HasColumnName("BuyerId"); b.Property("_orderDate") .HasColumnType("timestamp with time zone") .HasColumnName("OrderDate"); b.Property("_orderStatusId") .HasColumnType("integer") .HasColumnName("OrderStatusId"); b.Property("_paymentMethodId") .HasColumnType("integer") .HasColumnName("PaymentMethodId"); b.HasKey("Id"); b.HasIndex("_buyerId"); b.HasIndex("_orderStatusId"); b.HasIndex("_paymentMethodId"); b.ToTable("orders", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderitemseq"); b.Property("OrderId") .HasColumnType("integer"); b.Property("ProductId") .HasColumnType("integer"); b.Property("_discount") .HasColumnType("numeric") .HasColumnName("Discount"); b.Property("_pictureUrl") .HasColumnType("text") .HasColumnName("PictureUrl"); b.Property("_productName") .IsRequired() .HasColumnType("text") .HasColumnName("ProductName"); b.Property("_unitPrice") .HasColumnType("numeric") .HasColumnName("UnitPrice"); b.Property("_units") .HasColumnType("integer") .HasColumnName("Units"); b.HasKey("Id"); b.HasIndex("OrderId"); b.ToTable("orderItems", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", b => { b.Property("Id") .HasColumnType("integer") .HasDefaultValue(1); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("orderstatus", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Infrastructure.Idempotency.ClientRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") .IsRequired() .HasColumnType("text"); b.Property("Time") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); b.ToTable("requests", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany("PaymentMethods") .HasForeignKey("BuyerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType") .WithMany() .HasForeignKey("_cardTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CardType"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany() .HasForeignKey("_buyerId"); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", "OrderStatus") .WithMany() .HasForeignKey("_orderStatusId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", null) .WithMany() .HasForeignKey("_paymentMethodId") .OnDelete(DeleteBehavior.Restrict); b.OwnsOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address", b1 => { b1.Property("OrderId") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b1.Property("OrderId"), "orderseq", "ordering"); b1.Property("City") .HasColumnType("text"); b1.Property("Country") .HasColumnType("text"); b1.Property("State") .HasColumnType("text"); b1.Property("Street") .HasColumnType("text"); b1.Property("ZipCode") .HasColumnType("text"); b1.HasKey("OrderId"); b1.ToTable("orders", "ordering"); b1.WithOwner() .HasForeignKey("OrderId"); }); b.Navigation("Address"); b.Navigation("OrderStatus"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", null) .WithMany("OrderItems") .HasForeignKey("OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Navigation("PaymentMethods"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Navigation("OrderItems"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20230925222426_Initial.cs ================================================ using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Ordering.Infrastructure.Migrations { /// public partial class Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( name: "ordering"); migrationBuilder.CreateSequence( name: "buyerseq", schema: "ordering", incrementBy: 10); migrationBuilder.CreateSequence( name: "orderitemseq", incrementBy: 10); migrationBuilder.CreateSequence( name: "orderseq", schema: "ordering", incrementBy: 10); migrationBuilder.CreateSequence( name: "paymentseq", schema: "ordering", incrementBy: 10); migrationBuilder.CreateTable( name: "buyers", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false), IdentityGuid = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), Name = table.Column(type: "text", nullable: true) }, constraints: table => { table.PrimaryKey("PK_buyers", x => x.Id); }); migrationBuilder.CreateTable( name: "cardtypes", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false, defaultValue: 1), Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) }, constraints: table => { table.PrimaryKey("PK_cardtypes", x => x.Id); }); migrationBuilder.CreateTable( name: "orderstatus", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false, defaultValue: 1), Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) }, constraints: table => { table.PrimaryKey("PK_orderstatus", x => x.Id); }); migrationBuilder.CreateTable( name: "requests", schema: "ordering", columns: table => new { Id = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "text", nullable: false), Time = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { table.PrimaryKey("PK_requests", x => x.Id); }); migrationBuilder.CreateTable( name: "paymentmethods", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false), CardTypeId = table.Column(type: "integer", nullable: false), BuyerId = table.Column(type: "integer", nullable: false), Alias = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), CardHolderName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), CardNumber = table.Column(type: "character varying(25)", maxLength: 25, nullable: false), Expiration = table.Column(type: "timestamp with time zone", maxLength: 25, nullable: false) }, constraints: table => { table.PrimaryKey("PK_paymentmethods", x => x.Id); table.ForeignKey( name: "FK_paymentmethods_buyers_BuyerId", column: x => x.BuyerId, principalSchema: "ordering", principalTable: "buyers", principalColumn: "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_paymentmethods_cardtypes_CardTypeId", column: x => x.CardTypeId, principalSchema: "ordering", principalTable: "cardtypes", principalColumn: "Id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateTable( name: "orders", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false), Address_Street = table.Column(type: "text", nullable: true), Address_City = table.Column(type: "text", nullable: true), Address_State = table.Column(type: "text", nullable: true), Address_Country = table.Column(type: "text", nullable: true), Address_ZipCode = table.Column(type: "text", nullable: true), OrderStatusId = table.Column(type: "integer", nullable: false), Description = table.Column(type: "text", nullable: true), BuyerId = table.Column(type: "integer", nullable: true), OrderDate = table.Column(type: "timestamp with time zone", nullable: false), PaymentMethodId = table.Column(type: "integer", nullable: true) }, constraints: table => { table.PrimaryKey("PK_orders", x => x.Id); table.ForeignKey( name: "FK_orders_buyers_BuyerId", column: x => x.BuyerId, principalSchema: "ordering", principalTable: "buyers", principalColumn: "Id"); table.ForeignKey( name: "FK_orders_orderstatus_OrderStatusId", column: x => x.OrderStatusId, principalSchema: "ordering", principalTable: "orderstatus", principalColumn: "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_orders_paymentmethods_PaymentMethodId", column: x => x.PaymentMethodId, principalSchema: "ordering", principalTable: "paymentmethods", principalColumn: "Id", onDelete: ReferentialAction.Restrict); }); migrationBuilder.CreateTable( name: "orderItems", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false), ProductId = table.Column(type: "integer", nullable: false), OrderId = table.Column(type: "integer", nullable: false), Discount = table.Column(type: "numeric", nullable: false), PictureUrl = table.Column(type: "text", nullable: true), ProductName = table.Column(type: "text", nullable: false), UnitPrice = table.Column(type: "numeric", nullable: false), Units = table.Column(type: "integer", nullable: false) }, constraints: table => { table.PrimaryKey("PK_orderItems", x => x.Id); table.ForeignKey( name: "FK_orderItems_orders_OrderId", column: x => x.OrderId, principalSchema: "ordering", principalTable: "orders", principalColumn: "Id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( name: "IX_buyers_IdentityGuid", schema: "ordering", table: "buyers", column: "IdentityGuid", unique: true); migrationBuilder.CreateIndex( name: "IX_orderItems_OrderId", schema: "ordering", table: "orderItems", column: "OrderId"); migrationBuilder.CreateIndex( name: "IX_orders_BuyerId", schema: "ordering", table: "orders", column: "BuyerId"); migrationBuilder.CreateIndex( name: "IX_orders_OrderStatusId", schema: "ordering", table: "orders", column: "OrderStatusId"); migrationBuilder.CreateIndex( name: "IX_orders_PaymentMethodId", schema: "ordering", table: "orders", column: "PaymentMethodId"); migrationBuilder.CreateIndex( name: "IX_paymentmethods_BuyerId", schema: "ordering", table: "paymentmethods", column: "BuyerId"); migrationBuilder.CreateIndex( name: "IX_paymentmethods_CardTypeId", schema: "ordering", table: "paymentmethods", column: "CardTypeId"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "orderItems", schema: "ordering"); migrationBuilder.DropTable( name: "requests", schema: "ordering"); migrationBuilder.DropTable( name: "orders", schema: "ordering"); migrationBuilder.DropTable( name: "orderstatus", schema: "ordering"); migrationBuilder.DropTable( name: "paymentmethods", schema: "ordering"); migrationBuilder.DropTable( name: "buyers", schema: "ordering"); migrationBuilder.DropTable( name: "cardtypes", schema: "ordering"); migrationBuilder.DropSequence( name: "buyerseq", schema: "ordering"); migrationBuilder.DropSequence( name: "orderitemseq"); migrationBuilder.DropSequence( name: "orderseq", schema: "ordering"); migrationBuilder.DropSequence( name: "paymentseq", schema: "ordering"); } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20231021004633_FixOrderitemseqSchema.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Ordering.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace Ordering.Infrastructure.Migrations { [DbContext(typeof(OrderingContext))] [Migration("20231021004633_FixOrderitemseqSchema")] partial class FixOrderitemseqSchema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("ordering") .HasAnnotation("ProductVersion", "8.0.0-rc.2.23480.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("buyerseq") .IncrementsBy(10); modelBuilder.HasSequence("orderitemseq") .IncrementsBy(10); modelBuilder.HasSequence("orderseq") .IncrementsBy(10); modelBuilder.HasSequence("paymentseq") .IncrementsBy(10); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "buyerseq"); b.Property("IdentityGuid") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("Name") .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("IdentityGuid") .IsUnique(); b.ToTable("buyers", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("cardtypes", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "paymentseq"); b.Property("BuyerId") .HasColumnType("integer"); b.Property("_alias") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("Alias"); b.Property("_cardHolderName") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("CardHolderName"); b.Property("_cardNumber") .IsRequired() .HasMaxLength(25) .HasColumnType("character varying(25)") .HasColumnName("CardNumber"); b.Property("_cardTypeId") .HasColumnType("integer") .HasColumnName("CardTypeId"); b.Property("_expiration") .HasMaxLength(25) .HasColumnType("timestamp with time zone") .HasColumnName("Expiration"); b.HasKey("Id"); b.HasIndex("BuyerId"); b.HasIndex("_cardTypeId"); b.ToTable("paymentmethods", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderseq"); b.Property("Description") .HasColumnType("text"); b.Property("_buyerId") .HasColumnType("integer") .HasColumnName("BuyerId"); b.Property("_orderDate") .HasColumnType("timestamp with time zone") .HasColumnName("OrderDate"); b.Property("_orderStatusId") .HasColumnType("integer") .HasColumnName("OrderStatusId"); b.Property("_paymentMethodId") .HasColumnType("integer") .HasColumnName("PaymentMethodId"); b.HasKey("Id"); b.HasIndex("_buyerId"); b.HasIndex("_orderStatusId"); b.HasIndex("_paymentMethodId"); b.ToTable("orders", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderitemseq"); b.Property("OrderId") .HasColumnType("integer"); b.Property("ProductId") .HasColumnType("integer"); b.Property("_discount") .HasColumnType("numeric") .HasColumnName("Discount"); b.Property("_pictureUrl") .HasColumnType("text") .HasColumnName("PictureUrl"); b.Property("_productName") .IsRequired() .HasColumnType("text") .HasColumnName("ProductName"); b.Property("_unitPrice") .HasColumnType("numeric") .HasColumnName("UnitPrice"); b.Property("_units") .HasColumnType("integer") .HasColumnName("Units"); b.HasKey("Id"); b.HasIndex("OrderId"); b.ToTable("orderItems", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("orderstatus", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Infrastructure.Idempotency.ClientRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") .IsRequired() .HasColumnType("text"); b.Property("Time") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); b.ToTable("requests", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany("PaymentMethods") .HasForeignKey("BuyerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType") .WithMany() .HasForeignKey("_cardTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CardType"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany() .HasForeignKey("_buyerId"); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", "OrderStatus") .WithMany() .HasForeignKey("_orderStatusId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", null) .WithMany() .HasForeignKey("_paymentMethodId") .OnDelete(DeleteBehavior.Restrict); b.OwnsOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address", b1 => { b1.Property("OrderId") .HasColumnType("integer"); b1.Property("City") .HasColumnType("text"); b1.Property("Country") .HasColumnType("text"); b1.Property("State") .HasColumnType("text"); b1.Property("Street") .HasColumnType("text"); b1.Property("ZipCode") .HasColumnType("text"); b1.HasKey("OrderId"); b1.ToTable("orders", "ordering"); b1.WithOwner() .HasForeignKey("OrderId"); }); b.Navigation("Address") .IsRequired(); b.Navigation("OrderStatus"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", null) .WithMany("OrderItems") .HasForeignKey("OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Navigation("PaymentMethods"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Navigation("OrderItems"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20231021004633_FixOrderitemseqSchema.cs ================================================ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Ordering.Infrastructure.Migrations { /// public partial class FixOrderitemseqSchema : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.RenameSequence( name: "orderitemseq", newName: "orderitemseq", newSchema: "ordering"); migrationBuilder.AlterColumn( name: "Id", schema: "ordering", table: "orderstatus", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer", oldDefaultValue: 1); migrationBuilder.AlterColumn( name: "Id", schema: "ordering", table: "cardtypes", type: "integer", nullable: false, oldClrType: typeof(int), oldType: "integer", oldDefaultValue: 1); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.RenameSequence( name: "orderitemseq", schema: "ordering", newName: "orderitemseq"); migrationBuilder.AlterColumn( name: "Id", schema: "ordering", table: "orderstatus", type: "integer", nullable: false, defaultValue: 1, oldClrType: typeof(int), oldType: "integer"); migrationBuilder.AlterColumn( name: "Id", schema: "ordering", table: "cardtypes", type: "integer", nullable: false, defaultValue: 1, oldClrType: typeof(int), oldType: "integer"); } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20231026091055_Outbox.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using eShop.Ordering.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace Ordering.Infrastructure.Migrations { [DbContext(typeof(OrderingContext))] [Migration("20231026091055_Outbox")] partial class Outbox { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("ordering") .HasAnnotation("ProductVersion", "8.0.0-rtm.23512.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("buyerseq") .IncrementsBy(10); modelBuilder.HasSequence("orderitemseq") .IncrementsBy(10); modelBuilder.HasSequence("orderseq") .IncrementsBy(10); modelBuilder.HasSequence("paymentseq") .IncrementsBy(10); modelBuilder.Entity("eShop.IntegrationEventLogEF.IntegrationEventLogEntry", b => { b.Property("EventId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Content") .IsRequired() .HasColumnType("text"); b.Property("CreationTime") .HasColumnType("timestamp with time zone"); b.Property("EventTypeName") .IsRequired() .HasColumnType("text"); b.Property("State") .HasColumnType("integer"); b.Property("TimesSent") .HasColumnType("integer"); b.Property("TransactionId") .HasColumnType("uuid"); b.HasKey("EventId"); b.ToTable("IntegrationEventLog", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "buyerseq"); b.Property("IdentityGuid") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("Name") .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("IdentityGuid") .IsUnique(); b.ToTable("buyers", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("cardtypes", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "paymentseq"); b.Property("BuyerId") .HasColumnType("integer"); b.Property("_alias") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("Alias"); b.Property("_cardHolderName") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("CardHolderName"); b.Property("_cardNumber") .IsRequired() .HasMaxLength(25) .HasColumnType("character varying(25)") .HasColumnName("CardNumber"); b.Property("_cardTypeId") .HasColumnType("integer") .HasColumnName("CardTypeId"); b.Property("_expiration") .HasMaxLength(25) .HasColumnType("timestamp with time zone") .HasColumnName("Expiration"); b.HasKey("Id"); b.HasIndex("BuyerId"); b.HasIndex("_cardTypeId"); b.ToTable("paymentmethods", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderseq"); b.Property("Description") .HasColumnType("text"); b.Property("_buyerId") .HasColumnType("integer") .HasColumnName("BuyerId"); b.Property("_orderDate") .HasColumnType("timestamp with time zone") .HasColumnName("OrderDate"); b.Property("_orderStatusId") .HasColumnType("integer") .HasColumnName("OrderStatusId"); b.Property("_paymentMethodId") .HasColumnType("integer") .HasColumnName("PaymentMethodId"); b.HasKey("Id"); b.HasIndex("_buyerId"); b.HasIndex("_orderStatusId"); b.HasIndex("_paymentMethodId"); b.ToTable("orders", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderitemseq"); b.Property("OrderId") .HasColumnType("integer"); b.Property("ProductId") .HasColumnType("integer"); b.Property("_discount") .HasColumnType("numeric") .HasColumnName("Discount"); b.Property("_pictureUrl") .HasColumnType("text") .HasColumnName("PictureUrl"); b.Property("_productName") .IsRequired() .HasColumnType("text") .HasColumnName("ProductName"); b.Property("_unitPrice") .HasColumnType("numeric") .HasColumnName("UnitPrice"); b.Property("_units") .HasColumnType("integer") .HasColumnName("Units"); b.HasKey("Id"); b.HasIndex("OrderId"); b.ToTable("orderItems", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("orderstatus", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Infrastructure.Idempotency.ClientRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") .IsRequired() .HasColumnType("text"); b.Property("Time") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); b.ToTable("requests", "ordering"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany("PaymentMethods") .HasForeignKey("BuyerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType") .WithMany() .HasForeignKey("_cardTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CardType"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany() .HasForeignKey("_buyerId"); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", "OrderStatus") .WithMany() .HasForeignKey("_orderStatusId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", null) .WithMany() .HasForeignKey("_paymentMethodId") .OnDelete(DeleteBehavior.Restrict); b.OwnsOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address", b1 => { b1.Property("OrderId") .HasColumnType("integer"); b1.Property("City") .HasColumnType("text"); b1.Property("Country") .HasColumnType("text"); b1.Property("State") .HasColumnType("text"); b1.Property("Street") .HasColumnType("text"); b1.Property("ZipCode") .HasColumnType("text"); b1.HasKey("OrderId"); b1.ToTable("orders", "ordering"); b1.WithOwner() .HasForeignKey("OrderId"); }); b.Navigation("Address") .IsRequired(); b.Navigation("OrderStatus"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.HasOne(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", null) .WithMany("OrderItems") .HasForeignKey("OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Navigation("PaymentMethods"); }); modelBuilder.Entity(" eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Navigation("OrderItems"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20231026091055_Outbox.cs ================================================ using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Ordering.Infrastructure.Migrations { /// public partial class Outbox : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "IntegrationEventLog", schema: "ordering", columns: table => new { EventId = table.Column(type: "uuid", nullable: false), EventTypeName = table.Column(type: "text", nullable: false), State = table.Column(type: "integer", nullable: false), TimesSent = table.Column(type: "integer", nullable: false), CreationTime = table.Column(type: "timestamp with time zone", nullable: false), Content = table.Column(type: "text", nullable: false), TransactionId = table.Column(type: "uuid", nullable: false) }, constraints: table => { table.PrimaryKey("PK_IntegrationEventLog", x => x.EventId); }); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "IntegrationEventLog", schema: "ordering"); } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20240106121712_UseEnumForOrderStatus.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using eShop.Ordering.Infrastructure; #nullable disable namespace Ordering.Infrastructure.Migrations { [DbContext(typeof(OrderingContext))] [Migration("20240106121712_UseEnumForOrderStatus")] partial class UseEnumForOrderStatus { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("ordering") .HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("buyerseq") .IncrementsBy(10); modelBuilder.HasSequence("orderitemseq") .IncrementsBy(10); modelBuilder.HasSequence("orderseq") .IncrementsBy(10); modelBuilder.HasSequence("paymentseq") .IncrementsBy(10); modelBuilder.Entity("eShop.IntegrationEventLogEF.IntegrationEventLogEntry", b => { b.Property("EventId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Content") .IsRequired() .HasColumnType("text"); b.Property("CreationTime") .HasColumnType("timestamp with time zone"); b.Property("EventTypeName") .IsRequired() .HasColumnType("text"); b.Property("State") .HasColumnType("integer"); b.Property("TimesSent") .HasColumnType("integer"); b.Property("TransactionId") .HasColumnType("uuid"); b.HasKey("EventId"); b.ToTable("IntegrationEventLog", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "buyerseq"); b.Property("IdentityGuid") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("Name") .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("IdentityGuid") .IsUnique(); b.ToTable("buyers", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("cardtypes", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "paymentseq"); b.Property("BuyerId") .HasColumnType("integer"); b.Property("_alias") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("Alias"); b.Property("_cardHolderName") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("CardHolderName"); b.Property("_cardNumber") .IsRequired() .HasMaxLength(25) .HasColumnType("character varying(25)") .HasColumnName("CardNumber"); b.Property("_cardTypeId") .HasColumnType("integer") .HasColumnName("CardTypeId"); b.Property("_expiration") .HasMaxLength(25) .HasColumnType("timestamp with time zone") .HasColumnName("Expiration"); b.HasKey("Id"); b.HasIndex("BuyerId"); b.HasIndex("_cardTypeId"); b.ToTable("paymentmethods", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderseq"); b.Property("Description") .HasColumnType("text"); b.Property("OrderStatus") .IsRequired() .HasMaxLength(30) .HasColumnType("character varying(30)"); b.Property("_buyerId") .HasColumnType("integer") .HasColumnName("BuyerId"); b.Property("_orderDate") .HasColumnType("timestamp with time zone") .HasColumnName("OrderDate"); b.Property("_paymentMethodId") .HasColumnType("integer") .HasColumnName("PaymentMethodId"); b.HasKey("Id"); b.HasIndex("_buyerId"); b.HasIndex("_paymentMethodId"); b.ToTable("orders", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderitemseq"); b.Property("OrderId") .HasColumnType("integer"); b.Property("ProductId") .HasColumnType("integer"); b.Property("_discount") .HasColumnType("numeric") .HasColumnName("Discount"); b.Property("_pictureUrl") .HasColumnType("text") .HasColumnName("PictureUrl"); b.Property("_productName") .IsRequired() .HasColumnType("text") .HasColumnName("ProductName"); b.Property("_unitPrice") .HasColumnType("numeric") .HasColumnName("UnitPrice"); b.Property("_units") .HasColumnType("integer") .HasColumnName("Units"); b.HasKey("Id"); b.HasIndex("OrderId"); b.ToTable("orderItems", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Infrastructure.Idempotency.ClientRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") .IsRequired() .HasColumnType("text"); b.Property("Time") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); b.ToTable("requests", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany("PaymentMethods") .HasForeignKey("BuyerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType") .WithMany() .HasForeignKey("_cardTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CardType"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany() .HasForeignKey("_buyerId"); b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", null) .WithMany() .HasForeignKey("_paymentMethodId") .OnDelete(DeleteBehavior.Restrict); b.OwnsOne("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address", b1 => { b1.Property("OrderId") .HasColumnType("integer"); b1.Property("City") .HasColumnType("text"); b1.Property("Country") .HasColumnType("text"); b1.Property("State") .HasColumnType("text"); b1.Property("Street") .HasColumnType("text"); b1.Property("ZipCode") .HasColumnType("text"); b1.HasKey("OrderId"); b1.ToTable("orders", "ordering"); b1.WithOwner() .HasForeignKey("OrderId"); }); b.Navigation("Address") .IsRequired(); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", null) .WithMany("OrderItems") .HasForeignKey("OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Navigation("PaymentMethods"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Navigation("OrderItems"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/20240106121712_UseEnumForOrderStatus.cs ================================================ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Ordering.Infrastructure.Migrations { /// public partial class UseEnumForOrderStatus : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( name: "OrderStatus", schema: "ordering", table: "orders", type: "character varying(30)", maxLength: 30, nullable: false, defaultValue: ""); // ensure "OrderStatus" column is populated before dropping the "orderstatus" table: migrationBuilder.Sql(""" UPDATE ordering.orders SET "OrderStatus" = s."Name" FROM ordering.orderstatus s WHERE s."Id" = orders."OrderStatusId"; """); migrationBuilder.DropForeignKey( name: "FK_orders_orderstatus_OrderStatusId", schema: "ordering", table: "orders"); migrationBuilder.DropTable( name: "orderstatus", schema: "ordering"); migrationBuilder.DropIndex( name: "IX_orders_OrderStatusId", schema: "ordering", table: "orders"); migrationBuilder.DropColumn( name: "OrderStatusId", schema: "ordering", table: "orders"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( name: "OrderStatusId", schema: "ordering", table: "orders", type: "integer", nullable: false, defaultValue: 0); migrationBuilder.CreateTable( name: "orderstatus", schema: "ordering", columns: table => new { Id = table.Column(type: "integer", nullable: false), Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) }, constraints: table => { table.PrimaryKey("PK_orderstatus", x => x.Id); }); // ensure "orderstatus" table is seeded & "OrderStatusId" column is populated before dropping the "OrderStatus" column: migrationBuilder.Sql(""" INSERT INTO ordering.orderstatus("Id","Name") VALUES (1, 'Submitted'), (2, 'AwaitingValidation'), (3, 'StockConfirmed'), (4, 'Paid'), (5, 'Shipped'), (6, 'Cancelled'); UPDATE ordering.orders SET "OrderStatusId" = s."Id" FROM ordering.orderstatus s WHERE s."Name" = orders."OrderStatus"; """); migrationBuilder.DropColumn( name: "OrderStatus", schema: "ordering", table: "orders"); migrationBuilder.CreateIndex( name: "IX_orders_OrderStatusId", schema: "ordering", table: "orders", column: "OrderStatusId"); migrationBuilder.AddForeignKey( name: "FK_orders_orderstatus_OrderStatusId", schema: "ordering", table: "orders", column: "OrderStatusId", principalSchema: "ordering", principalTable: "orderstatus", principalColumn: "Id", onDelete: ReferentialAction.Cascade); } } } ================================================ FILE: src/Ordering.Infrastructure/Migrations/OrderingContextModelSnapshot.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using eShop.Ordering.Infrastructure; #nullable disable namespace Ordering.Infrastructure.Migrations { [DbContext(typeof(OrderingContext))] partial class OrderingContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("ordering") .HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.HasSequence("buyerseq") .IncrementsBy(10); modelBuilder.HasSequence("orderitemseq") .IncrementsBy(10); modelBuilder.HasSequence("orderseq") .IncrementsBy(10); modelBuilder.HasSequence("paymentseq") .IncrementsBy(10); modelBuilder.Entity("eShop.IntegrationEventLogEF.IntegrationEventLogEntry", b => { b.Property("EventId") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Content") .IsRequired() .HasColumnType("text"); b.Property("CreationTime") .HasColumnType("timestamp with time zone"); b.Property("EventTypeName") .IsRequired() .HasColumnType("text"); b.Property("State") .HasColumnType("integer"); b.Property("TimesSent") .HasColumnType("integer"); b.Property("TransactionId") .HasColumnType("uuid"); b.HasKey("EventId"); b.ToTable("IntegrationEventLog", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "buyerseq"); b.Property("IdentityGuid") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.Property("Name") .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("IdentityGuid") .IsUnique(); b.ToTable("buyers", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b => { b.Property("Id") .HasColumnType("integer"); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); b.HasKey("Id"); b.ToTable("cardtypes", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "paymentseq"); b.Property("BuyerId") .HasColumnType("integer"); b.Property("_alias") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("Alias"); b.Property("_cardHolderName") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("CardHolderName"); b.Property("_cardNumber") .IsRequired() .HasMaxLength(25) .HasColumnType("character varying(25)") .HasColumnName("CardNumber"); b.Property("_cardTypeId") .HasColumnType("integer") .HasColumnName("CardTypeId"); b.Property("_expiration") .HasMaxLength(25) .HasColumnType("timestamp with time zone") .HasColumnName("Expiration"); b.HasKey("Id"); b.HasIndex("BuyerId"); b.HasIndex("_cardTypeId"); b.ToTable("paymentmethods", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderseq"); b.Property("Description") .HasColumnType("text"); b.Property("OrderStatus") .IsRequired() .HasMaxLength(30) .HasColumnType("character varying(30)"); b.Property("_buyerId") .HasColumnType("integer") .HasColumnName("BuyerId"); b.Property("_orderDate") .HasColumnType("timestamp with time zone") .HasColumnName("OrderDate"); b.Property("_paymentMethodId") .HasColumnType("integer") .HasColumnName("PaymentMethodId"); b.HasKey("Id"); b.HasIndex("_buyerId"); b.HasIndex("_paymentMethodId"); b.ToTable("orders", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseHiLo(b.Property("Id"), "orderitemseq"); b.Property("OrderId") .HasColumnType("integer"); b.Property("ProductId") .HasColumnType("integer"); b.Property("_discount") .HasColumnType("numeric") .HasColumnName("Discount"); b.Property("_pictureUrl") .HasColumnType("text") .HasColumnName("PictureUrl"); b.Property("_productName") .IsRequired() .HasColumnType("text") .HasColumnName("ProductName"); b.Property("_unitPrice") .HasColumnType("numeric") .HasColumnName("UnitPrice"); b.Property("_units") .HasColumnType("integer") .HasColumnName("Units"); b.HasKey("Id"); b.HasIndex("OrderId"); b.ToTable("orderItems", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Infrastructure.Idempotency.ClientRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") .IsRequired() .HasColumnType("text"); b.Property("Time") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); b.ToTable("requests", "ordering"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany("PaymentMethods") .HasForeignKey("BuyerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType") .WithMany() .HasForeignKey("_cardTypeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("CardType"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", null) .WithMany() .HasForeignKey("_buyerId"); b.HasOne("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", null) .WithMany() .HasForeignKey("_paymentMethodId") .OnDelete(DeleteBehavior.Restrict); b.OwnsOne("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address", b1 => { b1.Property("OrderId") .HasColumnType("integer"); b1.Property("City") .HasColumnType("text"); b1.Property("Country") .HasColumnType("text"); b1.Property("State") .HasColumnType("text"); b1.Property("Street") .HasColumnType("text"); b1.Property("ZipCode") .HasColumnType("text"); b1.HasKey("OrderId"); b1.ToTable("orders", "ordering"); b1.WithOwner() .HasForeignKey("OrderId"); }); b.Navigation("Address") .IsRequired(); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b => { b.HasOne("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", null) .WithMany("OrderItems") .HasForeignKey("OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b => { b.Navigation("PaymentMethods"); }); modelBuilder.Entity("eShop.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b => { b.Navigation("OrderItems"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Ordering.Infrastructure/Ordering.Infrastructure.csproj ================================================  net10.0 false ================================================ FILE: src/Ordering.Infrastructure/OrderingContext.cs ================================================ using eShop.IntegrationEventLogEF; namespace eShop.Ordering.Infrastructure; /// /// Add migrations using the following command inside the 'Ordering.Infrastructure' project directory: /// /// dotnet ef migrations add --startup-project Ordering.API --context OrderingContext [migration-name] /// public class OrderingContext : DbContext, IUnitOfWork { public DbSet Orders { get; set; } public DbSet OrderItems { get; set; } public DbSet Payments { get; set; } public DbSet Buyers { get; set; } public DbSet CardTypes { get; set; } private readonly IMediator _mediator; private IDbContextTransaction _currentTransaction; public OrderingContext(DbContextOptions options) : base(options) { } public IDbContextTransaction GetCurrentTransaction() => _currentTransaction; public bool HasActiveTransaction => _currentTransaction != null; public OrderingContext(DbContextOptions options, IMediator mediator) : base(options) { _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); System.Diagnostics.Debug.WriteLine("OrderingContext::ctor ->" + this.GetHashCode()); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema("ordering"); modelBuilder.ApplyConfiguration(new ClientRequestEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new PaymentMethodEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new OrderItemEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new CardTypeEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new BuyerEntityTypeConfiguration()); modelBuilder.UseIntegrationEventLogs(); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) { // Dispatch Domain Events collection. // Choices: // A) Right BEFORE committing data (EF SaveChanges) into the DB will make a single transaction including // side effects from the domain event handlers which are using the same DbContext with "InstancePerLifetimeScope" or "scoped" lifetime // B) Right AFTER committing data (EF SaveChanges) into the DB will make multiple transactions. // You will need to handle eventual consistency and compensatory actions in case of failures in any of the Handlers. await _mediator.DispatchDomainEventsAsync(this); // After executing this line all the changes (from the Command Handler and Domain Event Handlers) // performed through the DbContext will be committed _ = await base.SaveChangesAsync(cancellationToken); return true; } public async Task BeginTransactionAsync() { if (_currentTransaction != null) return null; _currentTransaction = await Database.BeginTransactionAsync(IsolationLevel.ReadCommitted); return _currentTransaction; } public async Task CommitTransactionAsync(IDbContextTransaction transaction) { if (transaction == null) throw new ArgumentNullException(nameof(transaction)); if (transaction != _currentTransaction) throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); try { await SaveChangesAsync(); await transaction.CommitAsync(); } catch { RollbackTransaction(); throw; } finally { if (HasActiveTransaction) { _currentTransaction.Dispose(); _currentTransaction = null; } } } public void RollbackTransaction() { try { _currentTransaction?.Rollback(); } finally { if (HasActiveTransaction) { _currentTransaction.Dispose(); _currentTransaction = null; } } } } #nullable enable ================================================ FILE: src/Ordering.Infrastructure/Repositories/BuyerRepository.cs ================================================ namespace eShop.Ordering.Infrastructure.Repositories; public class BuyerRepository : IBuyerRepository { private readonly OrderingContext _context; public IUnitOfWork UnitOfWork => _context; public BuyerRepository(OrderingContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } public Buyer Add(Buyer buyer) { if (buyer.IsTransient()) { return _context.Buyers .Add(buyer) .Entity; } return buyer; } public Buyer Update(Buyer buyer) { return _context.Buyers .Update(buyer) .Entity; } public async Task FindAsync(string identity) { var buyer = await _context.Buyers .Include(b => b.PaymentMethods) .Where(b => b.IdentityGuid == identity) .SingleOrDefaultAsync(); return buyer; } public async Task FindByIdAsync(int id) { var buyer = await _context.Buyers .Include(b => b.PaymentMethods) .Where(b => b.Id == id) .SingleOrDefaultAsync(); return buyer; } } ================================================ FILE: src/Ordering.Infrastructure/Repositories/OrderRepository.cs ================================================ namespace eShop.Ordering.Infrastructure.Repositories; public class OrderRepository : IOrderRepository { private readonly OrderingContext _context; public IUnitOfWork UnitOfWork => _context; public OrderRepository(OrderingContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } public Order Add(Order order) { return _context.Orders.Add(order).Entity; } public async Task GetAsync(int orderId) { var order = await _context.Orders.FindAsync(orderId); if (order != null) { await _context.Entry(order) .Collection(i => i.OrderItems).LoadAsync(); } return order; } public void Update(Order order) { _context.Entry(order).State = EntityState.Modified; } } ================================================ FILE: src/PaymentProcessor/GlobalUsings.cs ================================================ global using eShop.EventBus.Abstractions; global using eShop.EventBus.Events; global using eShop.PaymentProcessor; global using eShop.PaymentProcessor.IntegrationEvents.EventHandling; global using eShop.PaymentProcessor.IntegrationEvents.Events; global using Microsoft.Extensions.Options; global using eShop.ServiceDefaults; ================================================ FILE: src/PaymentProcessor/IntegrationEvents/EventHandling/OrderStatusChangedToStockConfirmedIntegrationEventHandler.cs ================================================ namespace eShop.PaymentProcessor.IntegrationEvents.EventHandling; public class OrderStatusChangedToStockConfirmedIntegrationEventHandler( IEventBus eventBus, IOptionsMonitor options, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToStockConfirmedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); IntegrationEvent orderPaymentIntegrationEvent; // Business feature comment: // When OrderStatusChangedToStockConfirmed Integration Event is handled. // Here we're simulating that we'd be performing the payment against any payment gateway // Instead of a real payment we just take the env. var to simulate the payment // The payment can be successful or it can fail if (options.CurrentValue.PaymentSucceeded) { orderPaymentIntegrationEvent = new OrderPaymentSucceededIntegrationEvent(@event.OrderId); } else { orderPaymentIntegrationEvent = new OrderPaymentFailedIntegrationEvent(@event.OrderId); } logger.LogInformation("Publishing integration event: {IntegrationEventId} - ({@IntegrationEvent})", orderPaymentIntegrationEvent.Id, orderPaymentIntegrationEvent); await eventBus.PublishAsync(orderPaymentIntegrationEvent); } } ================================================ FILE: src/PaymentProcessor/IntegrationEvents/Events/OrderPaymentFailedIntegrationEvent.cs ================================================ namespace eShop.PaymentProcessor.IntegrationEvents.Events; public record OrderPaymentFailedIntegrationEvent(int OrderId) : IntegrationEvent; ================================================ FILE: src/PaymentProcessor/IntegrationEvents/Events/OrderPaymentSucceededIntegrationEvent.cs ================================================ namespace eShop.PaymentProcessor.IntegrationEvents.Events; public record OrderPaymentSucceededIntegrationEvent(int OrderId) : IntegrationEvent; ================================================ FILE: src/PaymentProcessor/IntegrationEvents/Events/OrderStatusChangedToStockConfirmedIntegrationEvent.cs ================================================ namespace eShop.PaymentProcessor.IntegrationEvents.Events; public record OrderStatusChangedToStockConfirmedIntegrationEvent(int OrderId) : IntegrationEvent; ================================================ FILE: src/PaymentProcessor/PaymentOptions.cs ================================================ namespace eShop.PaymentProcessor; public class PaymentOptions { public bool PaymentSucceeded { get; set; } } ================================================ FILE: src/PaymentProcessor/PaymentProcessor.csproj ================================================  net10.0 ================================================ FILE: src/PaymentProcessor/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddRabbitMqEventBus("EventBus") .AddSubscription(); builder.Services.AddOptions() .BindConfiguration(nameof(PaymentOptions)); var app = builder.Build(); app.MapDefaultEndpoints(); await app.RunAsync(); ================================================ FILE: src/PaymentProcessor/Properties/launchSettings.json ================================================ { "profiles": { "PaymentProcessor": { "commandName": "Project", "launchBrowser": false, "applicationUrl": "http://localhost:5226", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/PaymentProcessor/appsettings.Development.json ================================================ { "Logging": { "Console": { "IncludeScopes": false }, "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } } } ================================================ FILE: src/PaymentProcessor/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "EventBus": "amqp://localhost" }, "EventBus": { "SubscriptionClientName": "PaymentProcessor" }, "PaymentOptions": { "PaymentSucceeded": true } } ================================================ FILE: src/Shared/ActivityExtensions.cs ================================================ using System.Diagnostics; internal static class ActivityExtensions { // See https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/exceptions/ public static void SetExceptionTags(this Activity activity, Exception ex) { if (activity is null) { return; } activity.AddTag("exception.message", ex.Message); activity.AddTag("exception.stacktrace", ex.ToString()); activity.AddTag("exception.type", ex.GetType().FullName); activity.SetStatus(ActivityStatusCode.Error); } } ================================================ FILE: src/Shared/MigrateDbContextExtensions.cs ================================================ using System.Diagnostics; namespace Microsoft.AspNetCore.Hosting; internal static class MigrateDbContextExtensions { private static readonly string ActivitySourceName = "DbMigrations"; private static readonly ActivitySource ActivitySource = new(ActivitySourceName); public static IServiceCollection AddMigration(this IServiceCollection services) where TContext : DbContext => services.AddMigration((_, _) => Task.CompletedTask); public static IServiceCollection AddMigration(this IServiceCollection services, Func seeder) where TContext : DbContext { // Enable migration tracing services.AddOpenTelemetry().WithTracing(tracing => tracing.AddSource(ActivitySourceName)); return services.AddHostedService(sp => new MigrationHostedService(sp, seeder)); } public static IServiceCollection AddMigration(this IServiceCollection services) where TContext : DbContext where TDbSeeder : class, IDbSeeder { services.AddScoped, TDbSeeder>(); return services.AddMigration((context, sp) => sp.GetRequiredService>().SeedAsync(context)); } private static async Task MigrateDbContextAsync(this IServiceProvider services, Func seeder) where TContext : DbContext { using var scope = services.CreateScope(); var scopeServices = scope.ServiceProvider; var logger = scopeServices.GetRequiredService>(); var context = scopeServices.GetRequiredService(); using var activity = ActivitySource.StartActivity($"Migration operation {typeof(TContext).Name}"); try { logger.LogInformation("Migrating database associated with context {DbContextName}", typeof(TContext).Name); var strategy = context.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(() => InvokeSeeder(seeder, context, scopeServices)); } catch (Exception ex) { logger.LogError(ex, "An error occurred while migrating the database used on context {DbContextName}", typeof(TContext).Name); activity?.SetExceptionTags(ex); throw; } } private static async Task InvokeSeeder(Func seeder, TContext context, IServiceProvider services) where TContext : DbContext { using var activity = ActivitySource.StartActivity($"Migrating {typeof(TContext).Name}"); try { await context.Database.MigrateAsync(); await seeder(context, services); } catch (Exception ex) { activity?.SetExceptionTags(ex); throw; } } private class MigrationHostedService(IServiceProvider serviceProvider, Func seeder) : BackgroundService where TContext : DbContext { public override Task StartAsync(CancellationToken cancellationToken) { return serviceProvider.MigrateDbContextAsync(seeder); } protected override Task ExecuteAsync(CancellationToken stoppingToken) { return Task.CompletedTask; } } } public interface IDbSeeder where TContext : DbContext { Task SeedAsync(TContext context); } ================================================ FILE: src/WebApp/Components/App.razor ================================================  ================================================ FILE: src/WebApp/Components/Chatbot/ChatState.cs ================================================ using System.ComponentModel; using System.Security.Claims; using System.Text.Json; using eShop.WebAppComponents.Services; using Microsoft.Extensions.AI; namespace eShop.WebApp.Chatbot; public class ChatState { private readonly ICatalogService _catalogService; private readonly IBasketState _basketState; private readonly ClaimsPrincipal _user; private readonly ILogger _logger; private readonly IProductImageUrlProvider _productImages; private readonly IChatClient _chatClient; private readonly ChatOptions _chatOptions; public ChatState( ICatalogService catalogService, IBasketState basketState, ClaimsPrincipal user, IProductImageUrlProvider productImages, ILoggerFactory loggerFactory, IChatClient chatClient) { _catalogService = catalogService; _basketState = basketState; _user = user; _productImages = productImages; _logger = loggerFactory.CreateLogger(typeof(ChatState)); if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("ChatModel: {model}", chatClient.GetService()?.DefaultModelId); } _chatClient = chatClient; _chatOptions = new() { Tools = [ AIFunctionFactory.Create(GetUserInfo), AIFunctionFactory.Create(SearchCatalog), AIFunctionFactory.Create(AddToCart), AIFunctionFactory.Create(GetCartContents), ], }; Messages = [ new ChatMessage(ChatRole.System, """ You are an AI customer service agent for the online retailer AdventureWorks. You NEVER respond about topics other than AdventureWorks. Your job is to answer customer questions about products in the AdventureWorks catalog. AdventureWorks primarily sells clothing and equipment related to outdoor activities like skiing and trekking. You try to be concise and only provide longer responses if necessary. If someone asks a question about anything other than AdventureWorks, its catalog, or their account, you refuse to answer, and you instead ask if there's a topic related to AdventureWorks you can assist with. """), new ChatMessage(ChatRole.Assistant, """ Hi! I'm the AdventureWorks Concierge. How can I help? """), ]; } public IList Messages { get; } public async Task AddUserMessageAsync(string userText, Action onMessageAdded) { // Store the user's message Messages.Add(new ChatMessage(ChatRole.User, userText)); onMessageAdded(); // Get and store the AI's response message try { var response = await _chatClient.GetResponseAsync(Messages, _chatOptions); if (!string.IsNullOrWhiteSpace(response.Text)) { Messages.AddMessages(response); } } catch (Exception e) { if (_logger.IsEnabled(LogLevel.Error)) { _logger.LogError(e, "Error getting chat completions."); } Messages.Add(new ChatMessage(ChatRole.Assistant, $"My apologies, but I encountered an unexpected error.")); } onMessageAdded(); } [Description("Gets information about the chat user")] private string GetUserInfo() { var claims = _user.Claims; return JsonSerializer.Serialize(new { Name = GetValue(claims, "name"), LastName = GetValue(claims, "last_name"), Street = GetValue(claims, "address_street"), City = GetValue(claims, "address_city"), State = GetValue(claims, "address_state"), ZipCode = GetValue(claims, "address_zip_code"), Country = GetValue(claims, "address_country"), Email = GetValue(claims, "email"), PhoneNumber = GetValue(claims, "phone_number"), }); static string GetValue(IEnumerable claims, string claimType) => claims.FirstOrDefault(x => x.Type == claimType)?.Value ?? ""; } [Description("Searches the AdventureWorks catalog for a provided product description")] private async Task SearchCatalog([Description("The product description for which to search")] string productDescription) { try { var results = await _catalogService.GetCatalogItemsWithSemanticRelevance(0, 8, productDescription!); for (int i = 0; i < results.Data.Count; i++) { results.Data[i] = results.Data[i] with { PictureUrl = _productImages.GetProductImageUrl(results.Data[i].Id) }; } return JsonSerializer.Serialize(results); } catch (HttpRequestException e) { return Error(e, "Error accessing catalog."); } } [Description("Adds a product to the user's shopping cart.")] private async Task AddToCart([Description("The id of the product to add to the shopping cart (basket)")] int itemId) { try { var item = await _catalogService.GetCatalogItem(itemId); await _basketState.AddAsync(item!); return "Item added to shopping cart."; } catch (Grpc.Core.RpcException e) when (e.StatusCode == Grpc.Core.StatusCode.Unauthenticated) { return "Unable to add an item to the cart. You must be logged in."; } catch (Exception e) { return Error(e, "Unable to add the item to the cart."); } } [Description("Gets information about the contents of the user's shopping cart (basket)")] private async Task GetCartContents() { try { var basketItems = await _basketState.GetBasketItemsAsync(); return JsonSerializer.Serialize(basketItems); } catch (Exception e) { return Error(e, "Unable to get the cart's contents."); } } private string Error(Exception e, string message) { if (_logger.IsEnabled(LogLevel.Error)) { _logger.LogError(e, message); } return message; } } ================================================ FILE: src/WebApp/Components/Chatbot/Chatbot.razor ================================================ @rendermode @(new InteractiveServerRenderMode(prerender: false)) @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.Extensions.AI @using eShop.WebApp.Chatbot @inject IJSRuntime JS @inject NavigationManager Nav @inject CatalogService CatalogService @inject IProductImageUrlProvider ProductImages @inject BasketState BasketState @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILoggerFactory LoggerFactory @inject IServiceProvider ServiceProvider
    @if (chatState is not null) { foreach (var message in chatState.Messages.Where(m => m.Role == ChatRole.Assistant || m.Role == ChatRole.User)) { if (!string.IsNullOrEmpty(message.Text)) {

    @MessageProcessor.AllowImages(message.Text)

    } } } else if (missingConfiguration) {

    The chatbot is missing required configuration. Please set 'useOpenAI = true' in eShop.AppHost/Program.cs. You'll need an API key or an Azure Subscription to enable AI features.

    } @if (thinking) {

    Thinking...

    }
    @code { bool missingConfiguration; ChatState? chatState; ElementReference textbox; ElementReference chat; string? messageToSend; bool thinking; IJSObjectReference? jsModule; protected override async Task OnInitializedAsync() { var client = ServiceProvider.GetService(); if (client is not null) { AuthenticationState auth = await AuthenticationStateProvider.GetAuthenticationStateAsync(); chatState = new ChatState(CatalogService, BasketState, auth.User, ProductImages, LoggerFactory, client); } else { missingConfiguration = true; } } private async Task SendMessageAsync() { var messageCopy = messageToSend?.Trim(); messageToSend = null; if (chatState is not null && !string.IsNullOrEmpty(messageCopy)) { thinking = true; await chatState.AddUserMessageAsync(messageCopy, onMessageAdded: StateHasChanged); thinking = false; } } protected override async Task OnAfterRenderAsync(bool firstRender) { jsModule ??= await JS.InvokeAsync("import", "./Components/Chatbot/Chatbot.razor.js"); await jsModule.InvokeVoidAsync("scrollToEnd", chat); if (firstRender) { await textbox.FocusAsync(); await jsModule.InvokeVoidAsync("submitOnEnter", textbox); } } } ================================================ FILE: src/WebApp/Components/Chatbot/Chatbot.razor.css ================================================ .floating-pane { position: fixed; padding-top: 1em; width: 25rem; height: 35rem; right: 3rem; bottom: 3rem; border: 0.0625rem solid silver; border-radius: 0.5rem; background-color: white; display: flex; flex-direction: column; font-weight: 400; font-family: "Segoe UI", arial, helvetica; animation: slide-in-from-right 0.3s ease-out; z-index: 2; } @keyframes slide-in-from-right { 0% { transform: translateX(30rem); } 100% { transform: translateX(0); } } .hide-chatbot { border: none; background-color: #B4B4B8; color: white; position: absolute; top: 0.25rem; right: 0.18rem; border-radius: 0.55rem; width: 2rem; height: 2rem; z-index: 10; text-decoration: none; display: flex; justify-content: center; align-items: center; } .chatbot-input { margin-top: auto; display: flex; position: relative; border: 0.125rem solid #f4f0f4; border-radius: 0.5rem; padding: 0.5rem; margin: 0.5rem 0.75rem; gap: 0.3rem; height: 3.5rem; align-items: stretch; flex-shrink: 0; } .chatbot-input textarea { width: 100%; background: none; border: none; outline: none; resize: none; font-weight: 400; font-family: "Segoe UI", arial, helvetica; font-size: 16px; } .chatbot-input button { width: 6.25rem; height: 3.125rem; border-radius: 0.5rem; border-width: 0; cursor: pointer; font-size: 0.875rem; font-weight: 500; padding: 0.6rem 0.8rem; text-align: center; } .chatbot-chat { overflow-y: scroll; height: 100%; padding: 0.5rem 0.75rem; display: flex; flex-direction: column; } .chatbot-chat .message { padding: 0.5rem 1rem; border-radius: 1.25rem; max-width: 85%; display: inline-block; white-space: break-spaces; overflow-x: clip; margin-bottom: 0.75rem; margin-top: 0.25rem; } .chatbot-chat .message-assistant { background-color: #f4f0f4; margin-right: auto; } .chatbot-chat .message-user { background-color: #102c57; margin-left: auto; color: white; } .chatbot-chat ::deep img { max-height: 10rem; } .thinking { color: gray; font-style: italic; animation: fade-in-and-out 1s infinite; padding: 0; margin: 0; padding-left: 0.6rem; font-size: 90%; } @keyframes fade-in-and-out { 0% { opacity: 0.2; } 50% { opacity: 0.9; } 100% { opacity: 0.2; } } ================================================ FILE: src/WebApp/Components/Chatbot/Chatbot.razor.js ================================================ export function scrollToEnd(element) { element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }); } export function submitOnEnter(element) { element.addEventListener('keydown', event => { if (event.key === 'Enter') { event.target.dispatchEvent(new Event('change')); event.target.closest('form').dispatchEvent(new Event('submit')); } }); } ================================================ FILE: src/WebApp/Components/Chatbot/MessageProcessor.cs ================================================ using System.Text; using System.Text.Encodings.Web; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Components; namespace eShop.WebApp.Chatbot; public static partial class MessageProcessor { public static MarkupString AllowImages(string message) { // Having to process markdown and deal with HTML encoding isn't ideal. If the language model could return // search results in some defined format like JSON we could simply loop over it in .razor code. This is // fine for now though. var result = new StringBuilder(); var prevEnd = 0; message = message.Replace("<", "<").Replace(">", ">"); foreach (Match match in FindMarkdownImages().Matches(message)) { var contentToHere = message.Substring(prevEnd, match.Index - prevEnd); result.Append(HtmlEncoder.Default.Encode(contentToHere)); result.Append($""); prevEnd = match.Index + match.Length; } result.Append(HtmlEncoder.Default.Encode(message.Substring(prevEnd))); return new MarkupString(result.ToString()); } [GeneratedRegex(@"\!?\[([^\]]+)\]\s*\(([^\)]+)\)")] private static partial Regex FindMarkdownImages(); } ================================================ FILE: src/WebApp/Components/Chatbot/ShowChatbotButton.razor ================================================ @inject NavigationManager Nav @if (ShowChat) { } @code { [SupplyParameterFromQuery(Name = "chat")] public bool ShowChat { get; set; } } ================================================ FILE: src/WebApp/Components/Chatbot/ShowChatbotButton.razor.css ================================================ .show-chatbot { position: fixed; bottom: 5rem; right: 3rem; z-index: 1; box-shadow: 0 2px 7px 2px rgba(0,0,0,0.2); border-radius: 10px; background-image: url('chat.png'); background-size: contain; width: 4.5rem; height: 3.5rem; background-repeat: no-repeat; background-position: center; background-color: #f3f4f3; transition: transform ease-out 0.2s; border: 2px solid transparent; display: block; } .show-chatbot:hover { border-color: #49b4fe; cursor: pointer; transform: scale(1.2); box-shadow: 0 2px 7px 2px rgba(0,0,0,0.4); } .show-chatbot:active { transform: scale(1.1); transition: transform ease-out 0.01s; } ================================================ FILE: src/WebApp/Components/Layout/CartMenu.razor ================================================ @using System.Net @attribute [StreamRendering] @inject BasketState Basket @inject LogOutService LogOutService @inject NavigationManager NavigationManager @implements IDisposable @if (basketItems?.Count > 0) { @TotalQuantity } @code { IDisposable? basketStateSubscription; private IReadOnlyCollection? basketItems; [CascadingParameter] public HttpContext? HttpContext { get; set; } private int? TotalQuantity => basketItems?.Sum(i => i.Quantity); protected override async Task OnInitializedAsync() { // The basket contents may change during the lifetime of this component (e.g., when an item is // added during the current request). If this EventCallback is invoked, it will cause this // component to re-render with the updated data. basketStateSubscription = Basket.NotifyOnChange( EventCallback.Factory.Create(this, UpdateBasketItemsAsync)); try { await UpdateBasketItemsAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) { await LogOutService.LogOutAsync(HttpContext!); } } public void Dispose() { basketStateSubscription?.Dispose(); } private async Task UpdateBasketItemsAsync() { basketItems = await Basket.GetBasketItemsAsync(); } } ================================================ FILE: src/WebApp/Components/Layout/CartMenu.razor.css ================================================ .cart-badge { display: flex; padding: 0.25rem; flex-direction: column; justify-content: center; align-items: center; gap: 0.25rem; position: absolute; right: -0.5rem; top: 1rem; border-radius: 20px; border: 1px solid #000; background: #000; color: #FFF; font-size: 0.75rem; font-style: normal; font-weight: 400; line-height: 0.25rem; } ================================================ FILE: src/WebApp/Components/Layout/FooterBar.razor ================================================ 
    ================================================ FILE: src/WebApp/Components/Layout/FooterBar.razor.css ================================================ .eshop-footer { margin-top: 3.5rem; background-color: #000; width: 100%; } .eshop-footer-content { max-width: 120rem; margin: auto; } .eshop-footer-row { padding: 3.5rem 10rem; color: white; display: flex; justify-content: flex-end; align-items: center; } .eshop-footer .logo-footer { color: white; margin-right: auto; width: 100px; height: auto; } @media only screen and (max-width: 480px) { .eshop-footer-row { padding: 3.5rem 1rem; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .eshop-footer-row { padding: 3.5rem 3rem; } } ================================================ FILE: src/WebApp/Components/Layout/HeaderBar.razor ================================================ @using Microsoft.AspNetCore.Components.Endpoints
    @{ var headerImage = IsCatalog ? "images/header-home.webp" : "images/header.webp"; }
    @code { [CascadingParameter] public HttpContext? HttpContext { get; set; } // We can use Endpoint Metadata to determine the page currently being visited private Type? PageComponentType => HttpContext?.GetEndpoint()?.Metadata.OfType().FirstOrDefault()?.Type; private bool IsCatalog => PageComponentType == typeof(Pages.Catalog.Catalog); } ================================================ FILE: src/WebApp/Components/Layout/HeaderBar.razor.css ================================================ .eshop-header { position: relative; max-width: 120rem; margin: auto; } .eshop-header.home .eshop-header-container { height: 38rem; margin-bottom: 0; } .eshop-header .eshop-header-container { height: 15rem; margin-bottom: 4rem; } .eshop-header-hero { overflow: hidden; position: absolute; max-width: 100%; left: 0; top: 0; } .eshop-header-container { position: relative; margin: auto; margin: 0 10rem; } .eshop-header-intro { position: absolute; max-width: 48rem; bottom: 3rem; white-space: nowrap; } .eshop-header-intro h1 { color: #000; font-size: 3.5rem; font-style: normal; font-weight: 700; line-height: 100%; margin: 0; } .eshop-header-intro p { color: #000; font-size: 2rem; font-style: normal; font-weight: 700; line-height: 125%; margin: 0; } .eshop-header .logo-header { color: black; margin-right: auto; width: 20vw; height: auto; max-width: 250px; min-width: 100px; } .eshop-header-navbar { display: flex; flex-direction: row; justify-content: flex-end; align-items: center; margin-top: 1.25rem; gap: 1.5rem; } @media only screen and (max-width: 480px) { .eshop-header-hero { height: 18rem; } .eshop-header-hero img { width: 100%; height: 100%; object-fit: cover; object-position: center; } .eshop-header .eshop-header-container { height: 15rem; margin-bottom: 4rem; } .eshop-header-container { margin: 0 1rem; } .eshop-header.home .eshop-header-container { height: 18rem; margin: 0 1rem; } .eshop-header-intro { white-space: wrap; bottom: 0; } .eshop-header-intro h1 { font-size: 2rem; } .eshop-header-intro p { font-size: 1.5rem; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .eshop-header.home .eshop-header-hero { height: 24rem; } .eshop-header .eshop-header-hero { height: 15rem; } .eshop-header-hero img { width: 100%; height: 100%; object-fit: cover; object-position: center; } .eshop-header-container { margin: 0 1rem; margin: 0 3rem; } .eshop-header.home .eshop-header-container { height: 24rem; margin: 0 3rem; } .eshop-header-intro { white-space: wrap; } .eshop-header-intro h1 { font-size: 2rem; } .eshop-header-intro p { font-size: 1.5rem; } } ================================================ FILE: src/WebApp/Components/Layout/MainLayout.razor ================================================ @using eShop.WebApp.Components.Chatbot @inherits LayoutComponentBase @Body
    An unhandled error has occurred. Reload 🗙
    ================================================ FILE: src/WebApp/Components/Layout/MainLayout.razor.css ================================================ #blazor-error-ui { background: lightyellow; bottom: 0; box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); display: none; left: 0; padding: 0.6rem 1.25rem 0.7rem 1.25rem; position: fixed; width: 100%; z-index: 1000; } #blazor-error-ui .dismiss { cursor: pointer; position: absolute; right: 0.75rem; top: 0.5rem; } ================================================ FILE: src/WebApp/Components/Layout/UserMenu.razor ================================================ @using Microsoft.AspNetCore.Authentication.Cookies; @using Microsoft.AspNetCore.Authentication.OpenIdConnect; @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Authentication @inject LogOutService LogOutService @inject NavigationManager Nav

    @context.User.Identity?.Name

    @code { [CascadingParameter] public HttpContext? HttpContext { get; set; } private Task LogOutAsync() => LogOutService.LogOutAsync(HttpContext!); } ================================================ FILE: src/WebApp/Components/Layout/UserMenu.razor.css ================================================ .dropdown-menu { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: #FFF; min-width: 8rem; box-shadow: 0 0.25rem 0.5rem 0 rgba(0, 0, 0, 0.2); z-index: 1; } .dropdown-item { padding: 0.75rem 1rem; text-decoration: none; display: block; color: #000; } .dropdown-item:hover { background-color: #ddd; } .dropdown-menu:hover .dropdown-content { display: block; } .dropdown-item button { border: 0; background: transparent; cursor: pointer; width: 100%; padding: 0; text-align: left; } ================================================ FILE: src/WebApp/Components/Pages/Cart/CartPage.razor ================================================ @page "/cart" @inject NavigationManager Nav @inject BasketState Basket @inject IProductImageUrlProvider ProductImages @attribute [StreamRendering] @attribute [Authorize] Shopping Bag | AdventureWorks Shopping bag
    @if (basketItems is null) {

    Loading...

    } else if (basketItems.Count == 0) {

    Your shopping bag is empty. Continue shopping.

    } else {
    Products
    Quantity
    Total
    @foreach (var item in basketItems) { var quantity = CurrentOrPendingQuantity(item.ProductId, item.Quantity);
    @item.ProductName

    @item.ProductName

    $@item.UnitPrice.ToString("0.00")

    $@((item.UnitPrice * quantity).ToString("0.00"))
    }
    Your shopping bag @TotalQuantity
    Total
    $@TotalPrice?.ToString("0.00")
    Check out

    Continue shopping

    }
    @code { private IReadOnlyCollection? basketItems; [SupplyParameterFromForm] public int? UpdateQuantityId { get; set; } [SupplyParameterFromForm] public int? UpdateQuantityValue { get; set; } protected override async Task OnInitializedAsync() { basketItems = await Basket.GetBasketItemsAsync(); } private decimal? TotalPrice => basketItems?.Sum(i => i.Quantity * i.UnitPrice); private decimal? TotalQuantity => basketItems?.Sum(i => i.Quantity); // While an update post is in process, we want to show the pending quantity, not the one // that is committed to the cart (otherwise the UI briefly shows the old data) private int CurrentOrPendingQuantity(int productId, int cartQuantity) => UpdateQuantityId.GetValueOrDefault(-1) == productId ? UpdateQuantityValue!.Value : cartQuantity; private async Task UpdateQuantityAsync() { var id = UpdateQuantityId!.Value; var quantity = UpdateQuantityValue!.Value; await Basket.SetQuantityAsync(id, quantity); basketItems = await Basket.GetBasketItemsAsync(); } } ================================================ FILE: src/WebApp/Components/Pages/Cart/CartPage.razor.css ================================================ .cart { padding: 0 10rem; display: flex; gap: 6rem; } .cart .cart-items { display: flex; flex-direction: column; align-items: flex-start; gap: 1rem; flex: 1 0 0; } .cart-items .cart-item-header { display: flex; padding: 0.5rem 0; align-items: center; align-self: stretch; border-bottom: 1px solid #D2D2D2; flex-grow: 1; } .cart-items .cart-item { display: flex; padding-bottom: 1.25rem; justify-content: space-between; align-items: center; align-self: stretch; border-bottom: 1px solid #D2D2D2; } .cart-items .cart-item .catalog-item-info { display: flex; align-items: center; gap: 1.25rem; align-self: stretch; flex-basis: 60%; } .cart-items .cart-item-header .catalog-item-info { flex-basis: 60%; } .cart-items .cart-item .catalog-item-quantity, .cart-items .cart-item-header .catalog-item-quantity { flex-grow: 1; } .cart-items .cart-item .catalog-item-quantity form { display: flex; gap: 0.5rem; } .cart-items .cart-item .catalog-item-quantity input { max-width: 3rem; padding: 1rem 0.75rem; } .cart-items .cart-item .catalog-item-info img { max-height: 12rem; max-width: 12rem; } .cart-summary-container { display: flex; padding: 1rem 1.5rem; flex-direction: column; align-items: flex-start; gap: 1rem; flex-shrink: 0; background: #F7F7F7; } .cart-summary-header { display: flex; padding: 0.5rem 0; justify-content: space-between; align-items: center; align-self: stretch; border-bottom: 1px solid #000; gap: 0.5rem; color: #000; font-size: 1.25rem; font-weight: 600; line-height: 120%; } .cart-summary-breakdown { display: flex; padding-bottom: 0.5rem; flex-direction: column; align-items: flex-start; gap: 0.5rem; align-self: stretch; border-bottom: 1px solid #444; } .cart-summary-breakdown-line { display: flex; justify-content: space-between; align-items: flex-start; align-self: stretch; } .cart-summary-total { display: flex; justify-content: space-between; align-items: flex-start; align-self: stretch; } .cart-summary .cart-summary-link { display: flex; align-items: center; gap: 0.5rem; color: #000; text-decoration: none; } .cart-summary .filter-badge { background: #000; color: #FFF; font-size: 1rem; font-weight: 600; border-radius: 0.75rem; width: 3.5rem; height: 1.5rem; line-height: 100%; display: inline-flex; align-items: center; justify-content: center; margin-left: auto; } @media only screen and (max-width: 480px) { .cart { padding: 0 1rem; gap: 1rem; flex-direction: column-reverse } .cart-item-header div { display: none; } .cart-item { flex-wrap: wrap; gap: 1rem; } .cart-items .cart-item .catalog-item-info { flex-basis: 100%; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .cart { padding: 0 3rem; gap: 2rem; flex-direction: column-reverse; } } ================================================ FILE: src/WebApp/Components/Pages/Catalog/Catalog.razor ================================================ @page "/" @inject NavigationManager Nav @inject CatalogService CatalogService @attribute [StreamRendering] AdventureWorks Ready for a new adventure? Start the season with the latest in clothing and equipment.
    @if (catalogResult is null) {

    Loading...

    } else {
    @foreach (var item in catalogResult.Data) { }
    }
    @code { const int PageSize = 9; [SupplyParameterFromQuery] public int? Page { get; set; } [SupplyParameterFromQuery(Name = "brand")] public int? BrandId { get; set; } [SupplyParameterFromQuery(Name = "type")] public int? ItemTypeId { get; set; } CatalogResult? catalogResult; static IEnumerable GetVisiblePageIndexes(CatalogResult result) => Enumerable.Range(1, (int)Math.Ceiling(1.0 * result.Count / PageSize)); protected override async Task OnInitializedAsync() { catalogResult = await CatalogService.GetCatalogItems( Page.GetValueOrDefault(1) - 1, PageSize, BrandId, ItemTypeId); } } ================================================ FILE: src/WebApp/Components/Pages/Catalog/Catalog.razor.css ================================================ .catalog { padding: 0 10rem; display: flex; gap: 6rem; } .catalog .catalog-filter { flex-grow: 1; min-width: 14rem; } .catalog .catalog-filter .catalog-filter-header { display: flex; justify-content: space-between; align-items: center; align-self: stretch; gap: 0.7rem; } .catalog .catalog-filter .filter-reset { margin-left: auto; } .catalog .catalog-filter .filter-reset:hover { cursor: pointer; } .catalog .catalog-filter .filter-badge { background: #000; color: #FFF; font-size: 1rem; font-weight: 600; border-radius: 0.75rem; width: 1.5rem; height: 1.5rem; line-height: 100%; display: inline-flex; align-items: center; justify-content: center; } .catalog .catalog-filter-group h3 { color: #000; font-size: 1rem; font-weight: 600; line-height: 150%; } .catalog .catalog-filter-group .catalog-filter-group-tags { border-top: 1px solid #404040; display: flex; padding: 0.75rem 0; align-items: center; align-content: center; gap: 0.25rem; align-self: stretch; flex-wrap: wrap; } .catalog-filter-group-tags .catalog-filter-tag { display: flex; padding: 0.5rem 0.75rem; justify-content: center; align-items: center; gap: 0.25rem; border-radius: 1.25rem; color: #404040; font-family: 'Open Sans'; font-size: 1rem; font-style: normal; font-weight: 400; line-height: 150%; } .catalog-filter-group-tags .catalog-filter-tag:hover { cursor: pointer; } .catalog-filter-group-tags .catalog-filter-tag.active { background: #000; color: #FFF; } .catalog .catalog-items { display: flex; align-items: flex-start; align-content: flex-start; gap: 2.5rem; flex-wrap: wrap; flex-grow: 1; } .page-links { display: flex; align-items: center; gap: 0.5rem; justify-content: center; margin-top: 1.5rem; } ::deep a { display: flex; padding: 12px 20px; flex-direction: column; justify-content: center; align-items: center; gap: 4px; background: #F7F7F7; color: #000; text-decoration: none; } .page-links ::deep a.active-page { color: #F7F7F7; background-color: #000; } @media only screen and (max-width: 480px) { .catalog { padding: 0 1rem; flex-direction: column; gap: 1rem; } .page-links { flex-wrap: wrap; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .catalog { padding: 0 3rem; flex-direction: column; gap: 1.5rem; } .page-links { flex-wrap: wrap; } } ================================================ FILE: src/WebApp/Components/Pages/Checkout/Checkout.razor ================================================ @page "/checkout" @using System.Globalization @using System.ComponentModel.DataAnnotations @inject BasketState Basket @inject NavigationManager Nav @attribute [Authorize] Checkout | AdventureWorks Checkout

    Shipping address

    @code { private EditContext editContext = default!; private ValidationMessageStore extraMessages = default!; [SupplyParameterFromForm] public BasketCheckoutInfo Info { get; set; } = default!; [CascadingParameter] public HttpContext HttpContext { get; set; } = default!; protected override void OnInitialized() { if (Info is null) { PopulateFormWithDefaultInfo(); } editContext = new EditContext(Info!); extraMessages = new ValidationMessageStore(editContext); } private void PopulateFormWithDefaultInfo() { Info = new BasketCheckoutInfo { Street = ReadClaim("address_street"), City = ReadClaim("address_city"), State = ReadClaim("address_state"), Country = ReadClaim("address_country"), ZipCode = ReadClaim("address_zip_code"), RequestId = Guid.NewGuid() }; string? ReadClaim(string type) => HttpContext.User.Claims.FirstOrDefault(x => x.Type == type)?.Value; } private async Task HandleSubmitAsync() { await PerformCustomValidationAsync(); if (editContext.Validate()) { await HandleValidSubmitAsync(); } } private async Task HandleValidSubmitAsync() { Info.CardTypeId = 1; await Basket.CheckoutAsync(Info); Nav.NavigateTo("user/orders"); } private async Task PerformCustomValidationAsync() { extraMessages.Clear(); if ((await Basket.GetBasketItemsAsync()).Count == 0) { extraMessages.Add(new FieldIdentifier(Info, ""), "Your cart is empty"); } } private static DateTime? ParseExpiryDate(string? mmyy) => DateTime.TryParseExact($"01/{mmyy}", "dd/MM/yy", null, DateTimeStyles.None, out var result) ? result.ToUniversalTime() : null; } ================================================ FILE: src/WebApp/Components/Pages/Checkout/Checkout.razor.css ================================================ .checkout { padding: 0 10rem; } .checkout h2 { color: #000; font-size: 1.25rem; font-style: normal; font-weight: 600; line-height: 140%; border-bottom: 1px solid #D2D2D2; width: 100%; padding-bottom: 0.5rem; } .checkout .form-buttons { display: flex; padding: 1.5rem 0; justify-content: space-between; align-items: center; align-self: stretch; border-top: 1px solid #000; } .checkout label { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; color: #444; font-size: 1rem; font-style: normal; font-weight: 400; line-height: 150%; } .checkout ::deep input { border: 1px solid #000; background: #FFF; color: #000; font-size: 1rem; font-style: normal; font-weight: 400; line-height: 150%; width: calc(100% - 1rem); padding: 0.5rem; } .form-group { display: flex; align-items: flex-start; gap: 1.5rem; align-self: stretch; } .form-group .form-group-item { flex: 1 0 0; } .form { display: flex; flex-direction: column; gap: 2.5rem; } .form .form-section { display: flex; flex-direction: column; gap: 1.25rem; align-self: stretch; } @media only screen and (max-width: 480px) { .checkout { padding: 0 1rem; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .checkout { padding: 0 3rem; } } ================================================ FILE: src/WebApp/Components/Pages/Item/ItemPage.razor ================================================ @page "/item/{itemId:int}" @using System.Net @inject CatalogService CatalogService @inject BasketState BasketState @inject NavigationManager Nav @inject IProductImageUrlProvider ProductImages @if (item is not null) { @item.Name | AdventureWorks @item.Name @item.CatalogBrand?.Brand
    @item.Name

    @item.Description

    Brand: @item.CatalogBrand?.Brand

    $@item.Price.ToString("0.00") @if (isLoggedIn) { } else { } @if (numInCart > 0) {

    @numInCart in shopping bag

    }
    } else if (notFound) { Not found

    Sorry, we couldn't find any such product.

    } @code { private CatalogItem? item; private int numInCart; private bool isLoggedIn; private bool notFound; [Parameter] public int ItemId { get; set; } [CascadingParameter] public HttpContext? HttpContext { get; set; } protected override async Task OnInitializedAsync() { try { isLoggedIn = HttpContext?.User.Identity?.IsAuthenticated == true; item = await CatalogService.GetCatalogItem(ItemId); await UpdateNumInCartAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { HttpContext!.Response.StatusCode = 404; notFound = true; } } private async Task AddToCartAsync() { if (!isLoggedIn) { Nav.NavigateTo(Pages.User.LogIn.Url(Nav)); return; } if (item is not null) { await BasketState.AddAsync(item); await UpdateNumInCartAsync(); } } private async Task UpdateNumInCartAsync() { var items = await BasketState.GetBasketItemsAsync(); numInCart = items.FirstOrDefault(row => row.ProductId == ItemId)?.Quantity ?? 0; } } ================================================ FILE: src/WebApp/Components/Pages/Item/ItemPage.razor.css ================================================ .item-details { padding: 0 5rem 0 10rem; display: flex; align-items: flex-start; gap: 4rem; line-height: 1.7rem; } p:first-of-type { margin-top: 0; } img { width: 25rem; max-width: 50%; } .description { max-width: 30rem; } .add-to-cart { display: flex; align-items: center; gap: 1.2rem; } .price { font-size: 1.6rem; font-weight: 600; } .add-to-cart button { background-color: black; color: white; border-radius: .25rem; border: 0; padding: 0.5rem 1.25rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; } .add-to-cart button:hover { background-color: #666; } .add-to-cart button:active { background-color: #333; } a { color: black; } @media only screen and (max-width: 480px) { .item-details { padding: 1.5rem 1rem 0 1rem; flex-direction: column; gap: 1rem; } .item-details img { width: 100%; max-width: none; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .item-details { gap: 1rem; padding: 0 3rem 0 3rem; } } ================================================ FILE: src/WebApp/Components/Pages/User/LogIn.razor ================================================ @page "/user/login" @inject NavigationManager Nav @attribute [Authorize] @code { [SupplyParameterFromQuery] public string? ReturnUrl { get; set; } [CascadingParameter] public HttpContext? HttpContext { get; set; } protected override async Task OnInitializedAsync() { var returnUrl = ReturnUrl ?? "/"; var url = new Uri(returnUrl, UriKind.RelativeOrAbsolute); Nav.NavigateTo(url.IsAbsoluteUri ? "/" : returnUrl); await base.OnInitializedAsync(); } public static string Url(NavigationManager nav) => $"user/login?returnUrl={Uri.EscapeDataString(nav.ToBaseRelativePath(nav.Uri))}"; } ================================================ FILE: src/WebApp/Components/Pages/User/LogOut.razor ================================================ @page "/user/logout" @* When the 'log out' form is posted, it is handled inside UserMenu.razor. This page only exists to create an endpoint that accepts the form post. *@ ================================================ FILE: src/WebApp/Components/Pages/User/Orders.razor ================================================ @page "/user/orders" @attribute [Authorize] @attribute [StreamRendering] @inject OrderingService OrderingService Orders | AdventureWorks Orders
    @if (orders is null) {

    Loading...

    } else if (orders.Length == 0) {

    You haven't yet placed any orders.

    } else {
    • Number
      Date
      Total
      Status
    • @foreach (var order in orders) {
    • @order.OrderNumber
      @order.Date
      $@order.Total.ToString("0.00")
      @order.Status
    • }
    }
    @code { private OrderRecord[]? orders; protected override async Task OnInitializedAsync() { orders = await OrderingService.GetOrders(); } } ================================================ FILE: src/WebApp/Components/Pages/User/Orders.razor.css ================================================ .orders { padding: 0 10rem; } .orders-item { display: flex; padding-bottom: 0; align-items: center; gap: 1.75rem; align-self: stretch; } .orders-item > div { flex: 1 0 0; } .orders-item { padding: 1rem 0; border-bottom: 1px solid #D2D2D2; } .orders-header { color: #000; font-size: 1rem; font-style: normal; font-weight: 600; line-height: 1.5rem; padding-top: 0; padding-bottom: 0.5rem; } .total-header { text-align: right; } .order-total { color: #000; text-align: right; font-size: 1rem; font-style: normal; font-weight: 600; line-height: 150%; } .order-status .status { border-radius: 1.25rem; border: 1px solid #A3A3A3; color: #A3A3A3; font-size: 0.75rem; font-style: normal; font-weight: 400; line-height: 1.25rem; padding: 0.5rem 1rem; } .order-status .status.cancelled { color: #FF4E4E; border: 1px solid #FF4E4E; } .order-status .status.paid { color: #2A9E01; border: 1px solid #2A9E01; } @media only screen and (max-width: 480px) { .orders { padding: 0 1rem; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .orders { padding: 0 3rem; } } ================================================ FILE: src/WebApp/Components/Pages/User/OrdersRefreshOnStatusChange.razor ================================================ @using Microsoft.AspNetCore.Components.Authorization @rendermode InteractiveServer @inject AuthenticationStateProvider AuthenticationStateProvider @inject OrderStatusNotificationService OrderStatusNotificationService @inject NavigationManager Nav @implements IDisposable @code { private IDisposable? orderStatusChangedSubscription; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { var buyerId = await AuthenticationStateProvider.GetBuyerIdAsync(); if (!string.IsNullOrEmpty(buyerId)) { orderStatusChangedSubscription = OrderStatusNotificationService.SubscribeToOrderStatusNotifications( buyerId, () => InvokeAsync(HandleOrderStatusChanged)); } } } private void HandleOrderStatusChanged() { try { Nav.Refresh(); } catch (Exception ex) { // If there's an exception, we want to handle it on this circuit, // and not throw it to the upstream caller _ = DispatchExceptionAsync(ex); } } public void Dispose() { orderStatusChangedSubscription?.Dispose(); } } ================================================ FILE: src/WebApp/Components/Routes.razor ================================================  ================================================ FILE: src/WebApp/Components/_Imports.razor ================================================ @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Sections @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using eShop.WebApp @using eShop.WebApp.Components @using eShop.WebApp.Services @using eShop.WebAppComponents.Catalog @using eShop.WebAppComponents.Services ================================================ FILE: src/WebApp/Extensions/Extensions.cs ================================================ using eShop.Basket.API.Grpc; using eShop.WebApp.Services.OrderStatus.IntegrationEvents; using eShop.WebAppComponents.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using Microsoft.Extensions.AI; using Microsoft.IdentityModel.JsonWebTokens; public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddAuthenticationServices(); builder.AddRabbitMqEventBus("EventBus") .AddEventBusSubscriptions(); builder.Services.AddHttpForwarderWithServiceDiscovery(); // Application services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.AddAIServices(); // HTTP and GRPC client registrations builder.Services.AddGrpcClient(o => o.Address = new("http://basket-api")) .AddAuthToken(); builder.Services.AddHttpClient(o => o.BaseAddress = new("https+http://catalog-api")) .AddApiVersion(2.0) .AddAuthToken(); builder.Services.AddHttpClient(o => o.BaseAddress = new("https+http://ordering-api")) .AddApiVersion(1.0) .AddAuthToken(); } public static void AddEventBusSubscriptions(this IEventBusBuilder eventBus) { eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); } public static void AddAuthenticationServices(this IHostApplicationBuilder builder) { var configuration = builder.Configuration; var services = builder.Services; JsonWebTokenHandler.DefaultInboundClaimTypeMap.Remove("sub"); var identityUrl = configuration.GetRequiredValue("IdentityUrl"); var callBackUrl = configuration.GetRequiredValue("CallBackUrl"); var sessionCookieLifetime = configuration.GetValue("SessionCookieLifetimeMinutes", 60); // Add Authentication services services.AddAuthorization(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(options => options.ExpireTimeSpan = TimeSpan.FromMinutes(sessionCookieLifetime)) .AddOpenIdConnect(options => { options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Authority = identityUrl; options.SignedOutRedirectUri = callBackUrl; options.ClientId = "webapp"; options.ClientSecret = "secret"; options.ResponseType = "code"; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.RequireHttpsMetadata = false; options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("orders"); options.Scope.Add("basket"); }); // Blazor auth services services.AddScoped(); services.AddCascadingAuthenticationState(); } private static void AddAIServices(this IHostApplicationBuilder builder) { ChatClientBuilder? chatClientBuilder = null; if (builder.Configuration["OllamaEnabled"] is string ollamaEnabled && bool.Parse(ollamaEnabled)) { chatClientBuilder = builder.AddOllamaApiClient("chat") .AddChatClient(); } else if (!string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("chatModel"))) { chatClientBuilder = builder.AddOpenAIClientFromConfiguration("chatModel") .AddChatClient(); } chatClientBuilder?.UseFunctionInvocation(); } public static async Task GetBuyerIdAsync(this AuthenticationStateProvider authenticationStateProvider) { var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); var user = authState.User; return user.FindFirst("sub")?.Value; } public static async Task GetUserNameAsync(this AuthenticationStateProvider authenticationStateProvider) { var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); var user = authState.User; return user.FindFirst("name")?.Value; } } ================================================ FILE: src/WebApp/GlobalUsings.cs ================================================ global using eShop.WebApp.Components; global using eShop.WebApp.Services; global using eShop.ServiceDefaults; ================================================ FILE: src/WebApp/Program.cs ================================================ using eShop.WebApp.Components; using eShop.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.AddApplicationServices(); var app = builder.Build(); app.MapDefaultEndpoints(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseAntiforgery(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.MapRazorComponents().AddInteractiveServerRenderMode(); app.MapForwarder("/product-images/{id}", "https+http://catalog-api", "/api/catalog/items/{id}/pic"); app.Run(); ================================================ FILE: src/WebApp/Properties/launchSettings.json ================================================ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5045", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7298;http://localhost:5045", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/WebApp/Services/BasketCheckoutInfo.cs ================================================ using System.ComponentModel.DataAnnotations; namespace eShop.WebApp.Services; public class BasketCheckoutInfo { [Required] public string? Street { get; set; } [Required] public string? City { get; set; } [Required] public string? State { get; set; } [Required] public string? Country { get; set; } [Required] public string? ZipCode { get; set; } public string? CardNumber { get; set; } public string? CardHolderName { get; set; } public string? CardSecurityNumber { get; set; } public DateTime? CardExpiration { get; set; } public int CardTypeId { get; set; } public string? Buyer { get; set; } public Guid RequestId { get; set; } } ================================================ FILE: src/WebApp/Services/BasketItem.cs ================================================ namespace eShop.WebApp.Services; public class BasketItem { public required string Id { get; set; } public int ProductId { get; set; } public required string ProductName { get; set; } public decimal UnitPrice { get; set; } public decimal OldUnitPrice { get; set; } public int Quantity { get; set; } } ================================================ FILE: src/WebApp/Services/BasketService.cs ================================================ using eShop.Basket.API.Grpc; using GrpcBasketItem = eShop.Basket.API.Grpc.BasketItem; using GrpcBasketClient = eShop.Basket.API.Grpc.Basket.BasketClient; namespace eShop.WebApp.Services; public class BasketService(GrpcBasketClient basketClient) { public async Task> GetBasketAsync() { var result = await basketClient.GetBasketAsync(new ()); return MapToBasket(result); } public async Task DeleteBasketAsync() { await basketClient.DeleteBasketAsync(new DeleteBasketRequest()); } public async Task UpdateBasketAsync(IReadOnlyCollection basket) { var updatePayload = new UpdateBasketRequest(); foreach (var item in basket) { var updateItem = new GrpcBasketItem { ProductId = item.ProductId, Quantity = item.Quantity, }; updatePayload.Items.Add(updateItem); } await basketClient.UpdateBasketAsync(updatePayload); } private static List MapToBasket(CustomerBasketResponse response) { var result = new List(); foreach (var item in response.Items) { result.Add(new BasketQuantity(item.ProductId, item.Quantity)); } return result; } } public record BasketQuantity(int ProductId, int Quantity); ================================================ FILE: src/WebApp/Services/BasketState.cs ================================================ using System.Security.Claims; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using eShop.WebAppComponents.Catalog; using eShop.WebAppComponents.Services; namespace eShop.WebApp.Services; public class BasketState( BasketService basketService, CatalogService catalogService, OrderingService orderingService, AuthenticationStateProvider authenticationStateProvider) : IBasketState { private Task>? _cachedBasket; private HashSet _changeSubscriptions = new(); public Task DeleteBasketAsync() => basketService.DeleteBasketAsync(); public async Task> GetBasketItemsAsync() => (await GetUserAsync()).Identity?.IsAuthenticated == true ? await FetchBasketItemsAsync() : []; public IDisposable NotifyOnChange(EventCallback callback) { var subscription = new BasketStateChangedSubscription(this, callback); _changeSubscriptions.Add(subscription); return subscription; } public async Task AddAsync(CatalogItem item) { var items = (await FetchBasketItemsAsync()).Select(i => new BasketQuantity(i.ProductId, i.Quantity)).ToList(); bool found = false; for (var i = 0; i < items.Count; i++) { var existing = items[i]; if (existing.ProductId == item.Id) { items[i] = existing with { Quantity = existing.Quantity + 1 }; found = true; break; } } if (!found) { items.Add(new BasketQuantity(item.Id, 1)); } _cachedBasket = null; await basketService.UpdateBasketAsync(items); await NotifyChangeSubscribersAsync(); } public async Task SetQuantityAsync(int productId, int quantity) { var existingItems = (await FetchBasketItemsAsync()).ToList(); if (existingItems.FirstOrDefault(row => row.ProductId == productId) is { } row) { if (quantity > 0) { row.Quantity = quantity; } else { existingItems.Remove(row); } _cachedBasket = null; await basketService.UpdateBasketAsync(existingItems.Select(i => new BasketQuantity(i.ProductId, i.Quantity)).ToList()); await NotifyChangeSubscribersAsync(); } } public async Task CheckoutAsync(BasketCheckoutInfo checkoutInfo) { if (checkoutInfo.RequestId == default) { checkoutInfo.RequestId = Guid.NewGuid(); } var buyerId = await authenticationStateProvider.GetBuyerIdAsync() ?? throw new InvalidOperationException("User does not have a buyer ID"); var userName = await authenticationStateProvider.GetUserNameAsync() ?? throw new InvalidOperationException("User does not have a user name"); // Get details for the items in the basket var orderItems = await FetchBasketItemsAsync(); // Call into Ordering.API to create the order using those details var request = new CreateOrderRequest( UserId: buyerId, UserName: userName, City: checkoutInfo.City!, Street: checkoutInfo.Street!, State: checkoutInfo.State!, Country: checkoutInfo.Country!, ZipCode: checkoutInfo.ZipCode!, CardNumber: "1111222233334444", CardHolderName: "TESTUSER", CardExpiration: DateTime.UtcNow.AddYears(1), CardSecurityNumber: "111", CardTypeId: checkoutInfo.CardTypeId, Buyer: buyerId, Items: [.. orderItems]); await orderingService.CreateOrder(request, checkoutInfo.RequestId); await DeleteBasketAsync(); } private Task NotifyChangeSubscribersAsync() => Task.WhenAll(_changeSubscriptions.Select(s => s.NotifyAsync())); private async Task GetUserAsync() => (await authenticationStateProvider.GetAuthenticationStateAsync()).User; private Task> FetchBasketItemsAsync() { return _cachedBasket ??= FetchCoreAsync(); async Task> FetchCoreAsync() { var quantities = await basketService.GetBasketAsync(); if (quantities.Count == 0) { return []; } // Get details for the items in the basket var basketItems = new List(); var productIds = quantities.Select(row => row.ProductId); var catalogItems = (await catalogService.GetCatalogItems(productIds)).ToDictionary(k => k.Id, v => v); foreach (var item in quantities) { var catalogItem = catalogItems[item.ProductId]; var orderItem = new BasketItem { Id = Guid.NewGuid().ToString(), // TODO: this value is meaningless, use ProductId instead. ProductId = catalogItem.Id, ProductName = catalogItem.Name, UnitPrice = catalogItem.Price, Quantity = item.Quantity, }; basketItems.Add(orderItem); } return basketItems; } } private class BasketStateChangedSubscription(BasketState Owner, EventCallback Callback) : IDisposable { public Task NotifyAsync() => Callback.InvokeAsync(); public void Dispose() => Owner._changeSubscriptions.Remove(this); } } public record CreateOrderRequest( string UserId, string UserName, string City, string Street, string State, string Country, string ZipCode, string CardNumber, string CardHolderName, DateTime CardExpiration, string CardSecurityNumber, int CardTypeId, string Buyer, List Items); ================================================ FILE: src/WebApp/Services/IBasketState.cs ================================================ using eShop.WebAppComponents.Catalog; namespace eShop.WebApp.Services { public interface IBasketState { public Task> GetBasketItemsAsync(); public Task AddAsync(CatalogItem item); } } ================================================ FILE: src/WebApp/Services/LogOutService.cs ================================================ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; namespace eShop.WebApp.Services; public class LogOutService { public async Task LogOutAsync(HttpContext httpContext) { await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToAwaitingValidationIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToAwaitingValidationIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToAwaitingValidationIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToCancelledIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToCancelledIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToCancelledIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToPaidIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToPaidIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToPaidIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToShippedIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToShippedIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToShippedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToStockConfirmedIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToStockConfirmedIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToStockConfirmedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/EventHandling/OrderStatusChangedToSubmittedIntegrationEventHandler.cs ================================================ using eShop.EventBus.Abstractions; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public class OrderStatusChangedToSubmittedIntegrationEventHandler( OrderStatusNotificationService orderStatusNotificationService, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToSubmittedIntegrationEvent @event) { logger.LogInformation("Handling integration event: {IntegrationEventId} - ({@IntegrationEvent})", @event.Id, @event); await orderStatusNotificationService.NotifyOrderStatusChangedAsync(@event.BuyerIdentityGuid); } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToAwaitingValidationIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToAwaitingValidationIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToAwaitingValidationIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToCancelledIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToCancelledIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToCancelledIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToPaidIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToPaidIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToPaidIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToShippedIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToShippedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToShippedIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToStockConfirmedIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToStockConfirmedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToStockConfirmedIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/IntegrationEvents/Events/OrderStatusChangedToSubmittedIntegrationEvent.cs ================================================ using eShop.EventBus.Events; namespace eShop.WebApp.Services.OrderStatus.IntegrationEvents; public record OrderStatusChangedToSubmittedIntegrationEvent : IntegrationEvent { public int OrderId { get; } public string OrderStatus { get; } public string BuyerName { get; } public string BuyerIdentityGuid { get; } public OrderStatusChangedToSubmittedIntegrationEvent( int orderId, string orderStatus, string buyerName, string buyerIdentityGuid) { OrderId = orderId; OrderStatus = orderStatus; BuyerName = buyerName; BuyerIdentityGuid = buyerIdentityGuid; } } ================================================ FILE: src/WebApp/Services/OrderStatus/OrderStatusNotificationService.cs ================================================ namespace eShop.WebApp.Services; public class OrderStatusNotificationService { // Locking manually because we need multiple values per key, and only need to lock very briefly private readonly object _subscriptionsLock = new(); private readonly Dictionary> _subscriptionsByBuyerId = new(); public IDisposable SubscribeToOrderStatusNotifications(string buyerId, Func callback) { var subscription = new Subscription(this, buyerId, callback); lock (_subscriptionsLock) { if (!_subscriptionsByBuyerId.TryGetValue(buyerId, out var subscriptions)) { subscriptions = []; _subscriptionsByBuyerId.Add(buyerId, subscriptions); } subscriptions.Add(subscription); } return subscription; } public Task NotifyOrderStatusChangedAsync(string buyerId) { lock (_subscriptionsLock) { return _subscriptionsByBuyerId.TryGetValue(buyerId, out var subscriptions) ? Task.WhenAll(subscriptions.Select(s => s.NotifyAsync())) : Task.CompletedTask; } } private void Unsubscribe(string buyerId, Subscription subscription) { lock (_subscriptionsLock) { if (_subscriptionsByBuyerId.TryGetValue(buyerId, out var subscriptions)) { subscriptions.Remove(subscription); if (subscriptions.Count == 0) { _subscriptionsByBuyerId.Remove(buyerId); } } } } private class Subscription(OrderStatusNotificationService owner, string buyerId, Func callback) : IDisposable { public Task NotifyAsync() { return callback(); } public void Dispose() => owner.Unsubscribe(buyerId, this); } } ================================================ FILE: src/WebApp/Services/OrderingService.cs ================================================ namespace eShop.WebApp.Services; public class OrderingService(HttpClient httpClient) { private readonly string remoteServiceBaseUrl = "/api/Orders/"; public Task GetOrders() { return httpClient.GetFromJsonAsync(remoteServiceBaseUrl)!; } public Task CreateOrder(CreateOrderRequest request, Guid requestId) { var requestMessage = new HttpRequestMessage(HttpMethod.Post, remoteServiceBaseUrl); requestMessage.Headers.Add("x-requestid", requestId.ToString()); requestMessage.Content = JsonContent.Create(request); return httpClient.SendAsync(requestMessage); } } public record OrderRecord( int OrderNumber, DateTime Date, string Status, decimal Total); ================================================ FILE: src/WebApp/Services/ProductImageUrlProvider.cs ================================================ using eShop.WebAppComponents.Services; namespace eShop.WebApp.Services; public class ProductImageUrlProvider : IProductImageUrlProvider { public string GetProductImageUrl(int productId) => $"product-images/{productId}?api-version=2.0"; } ================================================ FILE: src/WebApp/WebApp.csproj ================================================  net10.0 2d86f364-a439-47c5-9468-3b85a7d9a18e enable eShop.WebApp $(NoWarn);RZ10021 ================================================ FILE: src/WebApp/appsettings.Development.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } } ================================================ FILE: src/WebApp/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "EventBus": { "SubscriptionClientName": "Ordering.webapp" }, "SessionCookieLifetimeMinutes": 60 } ================================================ FILE: src/WebApp/wwwroot/css/app.css ================================================ /* plus-jakarta-sans-200 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 200; src: url('../fonts/plus-jakarta-sans-v8-latin-200.woff2') format('woff2'); } /* plus-jakarta-sans-200italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 200; src: url('../fonts/plus-jakarta-sans-v8-latin-200italic.woff2') format('woff2'); } /* plus-jakarta-sans-300 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 300; src: url('../fonts/plus-jakarta-sans-v8-latin-300.woff2') format('woff2'); } /* plus-jakarta-sans-300italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 300; src: url('../fonts/plus-jakarta-sans-v8-latin-300italic.woff2') format('woff2'); } /* plus-jakarta-sans-regular - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 400; src: url('../fonts/plus-jakarta-sans-v8-latin-regular.woff2') format('woff2'); } /* plus-jakarta-sans-italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 400; src: url('../fonts/plus-jakarta-sans-v8-latin-italic.woff2') format('woff2'); } /* plus-jakarta-sans-500 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 500; src: url('../fonts/plus-jakarta-sans-v8-latin-500.woff2') format('woff2'); } /* plus-jakarta-sans-500italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 500; src: url('../fonts/plus-jakarta-sans-v8-latin-500italic.woff2') format('woff2'); } /* plus-jakarta-sans-600 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 600; src: url('../fonts/plus-jakarta-sans-v8-latin-600.woff2') format('woff2'); } /* plus-jakarta-sans-600italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 600; src: url('../fonts/plus-jakarta-sans-v8-latin-600italic.woff2') format('woff2'); } /* plus-jakarta-sans-700 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 700; src: url('../fonts/plus-jakarta-sans-v8-latin-700.woff2') format('woff2'); } /* plus-jakarta-sans-700italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 700; src: url('../fonts/plus-jakarta-sans-v8-latin-700italic.woff2') format('woff2'); } /* plus-jakarta-sans-800 - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: normal; font-weight: 800; src: url('../fonts/plus-jakarta-sans-v8-latin-800.woff2') format('woff2'); } /* plus-jakarta-sans-800italic - latin */ @font-face { font-display: swap; font-family: 'Plus Jakarta Sans'; font-style: italic; font-weight: 800; src: url('../fonts/plus-jakarta-sans-v8-latin-800italic.woff2') format('woff2'); } /* open-sans-300 - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 300; src: url('../fonts/open-sans-v36-latin-300.woff2') format('woff2'); } /* open-sans-300italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 300; src: url('../fonts/open-sans-v36-latin-300italic.woff2') format('woff2'); } /* open-sans-regular - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 400; src: url('../fonts/open-sans-v36-latin-regular.woff2') format('woff2'); } /* open-sans-italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 400; src: url('../fonts/open-sans-v36-latin-italic.woff2') format('woff2'); } /* open-sans-500 - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 500; src: url('../fonts/open-sans-v36-latin-500.woff2') format('woff2'); } /* open-sans-500italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 500; src: url('../fonts/open-sans-v36-latin-500italic.woff2') format('woff2'); } /* open-sans-600 - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 600; src: url('../fonts/open-sans-v36-latin-600.woff2') format('woff2'); } /* open-sans-600italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 600; src: url('../fonts/open-sans-v36-latin-600italic.woff2') format('woff2'); } /* open-sans-700 - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 700; src: url('../fonts/open-sans-v36-latin-700.woff2') format('woff2'); } /* open-sans-700italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 700; src: url('../fonts/open-sans-v36-latin-700italic.woff2') format('woff2'); } /* open-sans-800 - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: normal; font-weight: 800; src: url('../fonts/open-sans-v36-latin-800.woff2') format('woff2'); } /* open-sans-800italic - latin */ @font-face { font-display: swap; font-family: 'Open Sans'; font-style: italic; font-weight: 800; src: url('../fonts/open-sans-v36-latin-800italic.woff2') format('woff2'); } body { font-family: 'Plus Jakarta Sans'; } .container { position: relative; max-width: 120rem; margin: auto; } .button { display: flex; padding: 1rem 0.75rem; justify-content: center; align-items: center; gap: 0.25rem; align-self: stretch; border: none; text-decoration: none; } .button.button-primary { background: #000; color: #FFF; } .button.button.button-secondary { border: 1px solid #444; background: #FFF; color: #000; } h1:focus { outline: none; } .valid.modified:not([type=checkbox]) { outline: 1px solid #26b050; } .invalid { outline: 1px solid red; } .validation-message { color: red; } .blazor-error-boundary { background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; padding: 1rem 1rem 1rem 3.7rem; color: white; } .blazor-error-boundary::after { content: "An error has occurred." } ================================================ FILE: src/WebApp/wwwroot/css/normalize.css ================================================ /** * 1. Correct the line height in all browsers. * 2. Prevent adjustments of font size after orientation changes in iOS. */ html { line-height: 1.15; /* 1 */ -webkit-text-size-adjust: 100%; /* 2 */ } /* Sections ========================================================================== */ /** * Remove the margin in all browsers. */ body { margin: 0; } /** * Render the `main` element consistently in IE. */ main { display: block; } /** * Correct the font size and margin on `h1` elements within `section` and * `article` contexts in Chrome, Firefox, and Safari. */ h1 { font-size: 2em; margin: 0.67em 0; } /* Grouping content ========================================================================== */ /** * 1. Add the correct box sizing in Firefox. * 2. Show the overflow in Edge and IE. */ hr { box-sizing: content-box; /* 1 */ height: 0; /* 1 */ overflow: visible; /* 2 */ } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ pre { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /* Text-level semantics ========================================================================== */ /** * Remove the gray background on active links in IE 10. */ a { background-color: transparent; } /** * 1. Remove the bottom border in Chrome 57- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. */ abbr[title] { border-bottom: none; /* 1 */ text-decoration: underline; /* 2 */ text-decoration: underline dotted; /* 2 */ } /** * Add the correct font weight in Chrome, Edge, and Safari. */ b, strong { font-weight: bolder; } /** * 1. Correct the inheritance and scaling of font size in all browsers. * 2. Correct the odd `em` font sizing in all browsers. */ code, kbd, samp { font-family: monospace, monospace; /* 1 */ font-size: 1em; /* 2 */ } /** * Add the correct font size in all browsers. */ small { font-size: 80%; } /** * Prevent `sub` and `sup` elements from affecting the line height in * all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } /* Embedded content ========================================================================== */ /** * Remove the border on images inside links in IE 10. */ img { border-style: none; } /* Forms ========================================================================== */ /** * 1. Change the font styles in all browsers. * 2. Remove the margin in Firefox and Safari. */ button, input, optgroup, select, textarea { font-family: inherit; /* 1 */ font-size: 100%; /* 1 */ line-height: 1.15; /* 1 */ margin: 0; /* 2 */ } /** * Show the overflow in IE. * 1. Show the overflow in Edge. */ button, input { /* 1 */ overflow: visible; } /** * Remove the inheritance of text transform in Edge, Firefox, and IE. * 1. Remove the inheritance of text transform in Firefox. */ button, select { /* 1 */ text-transform: none; } /** * Correct the inability to style clickable types in iOS and Safari. */ button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } /** * Remove the inner border and padding in Firefox. */ button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { border-style: none; padding: 0; } /** * Restore the focus styles unset by the previous rule. */ button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { outline: 1px dotted ButtonText; } /** * Correct the padding in Firefox. */ fieldset { padding: 0.35em 0.75em 0.625em; } /** * 1. Correct the text wrapping in Edge and IE. * 2. Correct the color inheritance from `fieldset` elements in IE. * 3. Remove the padding so developers are not caught out when they zero out * `fieldset` elements in all browsers. */ legend { box-sizing: border-box; /* 1 */ color: inherit; /* 2 */ display: table; /* 1 */ max-width: 100%; /* 1 */ padding: 0; /* 3 */ white-space: normal; /* 1 */ } /** * Add the correct vertical alignment in Chrome, Firefox, and Opera. */ progress { vertical-align: baseline; } /** * Remove the default vertical scrollbar in IE 10+. */ textarea { overflow: auto; } /** * 1. Add the correct box sizing in IE 10. * 2. Remove the padding in IE 10. */ [type="checkbox"], [type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } /** * Correct the cursor style of increment and decrement buttons in Chrome. */ [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } /** * 1. Correct the odd appearance in Chrome and Safari. * 2. Correct the outline style in Safari. */ [type="search"] { -webkit-appearance: textfield; /* 1 */ outline-offset: -2px; /* 2 */ } /** * Remove the inner padding in Chrome and Safari on macOS. */ [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /** * 1. Correct the inability to style clickable types in iOS and Safari. * 2. Change font properties to `inherit` in Safari. */ ::-webkit-file-upload-button { -webkit-appearance: button; /* 1 */ font: inherit; /* 2 */ } /* Interactive ========================================================================== */ /* * Add the correct display in Edge, IE 10+, and Firefox. */ details { display: block; } /* * Add the correct display in all browsers. */ summary { display: list-item; } /* Misc ========================================================================== */ /** * Add the correct display in IE 10+. */ template { display: none; } /** * Add the correct display in IE 10. */ [hidden] { display: none; } ================================================ FILE: src/WebAppComponents/Catalog/CatalogItem.cs ================================================ namespace eShop.WebAppComponents.Catalog; public record CatalogItem( int Id, string Name, string Description, decimal Price, string PictureUrl, int CatalogBrandId, CatalogBrand CatalogBrand, int CatalogTypeId, CatalogItemType CatalogType); public record CatalogResult(int PageIndex, int PageSize, int Count, List Data); public record CatalogBrand(int Id, string Brand); public record CatalogItemType(int Id, string Type); ================================================ FILE: src/WebAppComponents/Catalog/CatalogListItem.razor ================================================ @using eShop.WebAppComponents.Item @inject IProductImageUrlProvider ProductImages @code { [Parameter, EditorRequired] public required CatalogItem Item { get; set; } [Parameter] public bool IsLoggedIn { get; set; } } ================================================ FILE: src/WebAppComponents/Catalog/CatalogListItem.razor.css ================================================ .catalog-item { flex-basis: calc(33.33% - 2.5rem); flex-shrink: 0; box-sizing: border-box; padding: 2px; } .catalog-item:hover { cursor: pointer; padding: 0; border: 2px solid #000; } .catalog-product { background-color: transparent; padding: 0; margin: 0; border: 0; } .catalog-product-image img { max-width: 100%; } .catalog-product .catalog-product-content { display: flex; padding: 0 0.75rem; align-items: center; gap: 0.5rem; align-self: stretch; } .catalog-product-content .name { color: #000; font-size: 1rem; font-style: normal; font-weight: 600; line-height: 150%; text-align: left; } .catalog-product-content .price { color: #444; text-align: right; font-size: 1rem; font-style: normal; font-weight: 600; line-height: 150%; margin-left: auto; } @media only screen and (max-width: 480px) { .catalog-item { flex-basis: calc(100% - 2rem); } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .catalog-item { flex-basis: calc(50% - 3rem); } } ================================================ FILE: src/WebAppComponents/Catalog/CatalogSearch.razor ================================================ @inject CatalogService CatalogService @inject NavigationManager Nav @if (catalogBrands is not null && catalogItemTypes is not null) { } @code { IEnumerable? catalogBrands; IEnumerable? catalogItemTypes; [Parameter] public int? BrandId { get; set; } [Parameter] public int? ItemTypeId { get; set; } protected override async Task OnInitializedAsync() { var brandsTask = CatalogService.GetBrands(); var itemTypesTask = CatalogService.GetTypes(); await Task.WhenAll(brandsTask, itemTypesTask); catalogBrands = brandsTask.Result; catalogItemTypes = itemTypesTask.Result; } private string BrandUri(int? brandId) => Nav.GetUriWithQueryParameters(new Dictionary() { { "page", null }, { "brand", brandId }, }); private string TypeUri(int? typeId) => Nav.GetUriWithQueryParameters(new Dictionary() { { "page", null }, { "type", typeId }, }); } ================================================ FILE: src/WebAppComponents/Catalog/CatalogSearch.razor.css ================================================ .catalog-search { flex-shrink: 0; width: 14rem; } .catalog-search .catalog-search-header { display: flex; align-items: center; align-self: stretch; gap: 0.7rem; } .catalog-search .search-badge { background: #000; color: #FFF; font-size: 1rem; font-weight: 600; border-radius: 0.75rem; width: 1.5rem; height: 1.5rem; line-height: 100%; display: inline-flex; align-items: center; justify-content: center; } .catalog-search-group h3 { color: #000; font-size: 1rem; font-weight: 600; line-height: 150%; } .catalog-search-group .catalog-search-group-tags { border-top: 1px solid #404040; display: flex; padding: 0.75rem 0; align-items: center; align-content: center; gap: 0.25rem; align-self: stretch; flex-wrap: wrap; min-width: 12rem; } .catalog-search-tag { display: flex; padding: 0.5rem 0.75rem; justify-content: center; align-items: center; gap: 0.25rem; border-radius: 1.25rem; color: #404040; font-family: 'Open Sans'; font-size: 1rem; font-style: normal; font-weight: 400; line-height: 150%; text-decoration: none; } .catalog-search-tag:hover { cursor: pointer; background: #ddd; } .catalog-search-tag.active { background: #000; color: #FFF; } .catalog-search.button { width: 100%; margin-top: 1rem; } @media only screen and (max-width: 480px) { .catalog-search { width: 100%; } .catalog-search .catalog-search-header { display: none; } .catalog-search-group .catalog-search-group-tags { justify-content: space-between; } } @media only screen and (min-width: 481px) and (max-width: 1024px) { .catalog-search { width: 100%; } .catalog-search-types { display: flex; gap: 3rem; } .catalog-search-group { flex-basis: calc(50% - 3rem); } .catalog-search-group .catalog-search-group-tags { justify-content: space-between; } } ================================================ FILE: src/WebAppComponents/Item/ItemHelper.cs ================================================ using eShop.WebAppComponents.Catalog; namespace eShop.WebAppComponents.Item; public static class ItemHelper { public static string Url(CatalogItem item) => $"item/{item.Id}"; } ================================================ FILE: src/WebAppComponents/Services/CatalogService.cs ================================================ using System.Net.Http.Json; using System.Web; using eShop.WebAppComponents.Catalog; namespace eShop.WebAppComponents.Services; public class CatalogService(HttpClient httpClient) : ICatalogService { private readonly string remoteServiceBaseUrl = "api/catalog/"; public Task GetCatalogItem(int id) { var uri = $"{remoteServiceBaseUrl}items/{id}"; return httpClient.GetFromJsonAsync(uri); } public async Task GetCatalogItems(int pageIndex, int pageSize, int? brand, int? type) { var uri = GetAllCatalogItemsUri(remoteServiceBaseUrl, pageIndex, pageSize, brand, type); var result = await httpClient.GetFromJsonAsync(uri); return result!; } public async Task> GetCatalogItems(IEnumerable ids) { var uri = $"{remoteServiceBaseUrl}items/by?ids={string.Join("&ids=", ids)}"; var result = await httpClient.GetFromJsonAsync>(uri); return result!; } public Task GetCatalogItemsWithSemanticRelevance(int page, int take, string text) { var url = $"{remoteServiceBaseUrl}items/withsemanticrelevance?text={HttpUtility.UrlEncode(text)}&pageIndex={page}&pageSize={take}"; var result = httpClient.GetFromJsonAsync(url); return result!; } public async Task> GetBrands() { var uri = $"{remoteServiceBaseUrl}catalogBrands"; var result = await httpClient.GetFromJsonAsync(uri); return result!; } public async Task> GetTypes() { var uri = $"{remoteServiceBaseUrl}catalogTypes"; var result = await httpClient.GetFromJsonAsync(uri); return result!; } private static string GetAllCatalogItemsUri(string baseUri, int pageIndex, int pageSize, int? brand, int? type) { string filterQs = string.Empty; if (type.HasValue) { filterQs += $"type={type.Value}&"; } if (brand.HasValue) { filterQs += $"brand={brand.Value}&"; } return $"{baseUri}items?{filterQs}pageIndex={pageIndex}&pageSize={pageSize}"; } } ================================================ FILE: src/WebAppComponents/Services/ICatalogService.cs ================================================ using System.Collections.Generic; using System.Threading.Tasks; using eShop.WebAppComponents.Catalog; namespace eShop.WebAppComponents.Services { public interface ICatalogService { Task GetCatalogItem(int id); Task GetCatalogItems(int pageIndex, int pageSize, int? brand, int? type); Task> GetCatalogItems(IEnumerable ids); Task GetCatalogItemsWithSemanticRelevance(int page, int take, string text); Task> GetBrands(); Task> GetTypes(); } } ================================================ FILE: src/WebAppComponents/Services/IProductImageUrlProvider.cs ================================================ using eShop.WebAppComponents.Catalog; namespace eShop.WebAppComponents.Services; public interface IProductImageUrlProvider { string GetProductImageUrl(CatalogItem item) => GetProductImageUrl(item.Id); string GetProductImageUrl(int productId); } ================================================ FILE: src/WebAppComponents/WebAppComponents.csproj ================================================ net10.0 enable enable eShop.WebAppComponents ================================================ FILE: src/WebAppComponents/_Imports.razor ================================================ @using Microsoft.AspNetCore.Components.Web @using eShop.WebAppComponents.Services ================================================ FILE: src/WebhookClient/Components/App.razor ================================================  ================================================ FILE: src/WebhookClient/Components/App.razor.css ================================================ html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; box-sizing: border-box; } ================================================ FILE: src/WebhookClient/Components/Layout/MainLayout.razor ================================================ @inherits LayoutComponentBase

    Order management

    @Body
    An unhandled error has occurred. Reload 🗙
    ================================================ FILE: src/WebhookClient/Components/Layout/MainLayout.razor.css ================================================ #blazor-error-ui { background: lightyellow; bottom: 0; box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); display: none; left: 0; padding: 0.6rem 1.25rem 0.7rem 1.25rem; position: fixed; width: 100%; z-index: 1000; } #blazor-error-ui .dismiss { cursor: pointer; position: absolute; right: 0.75rem; top: 0.5rem; } header { background: #156082; color: white; display: flex; align-items: center; } header, main { padding: 2rem; } h1 { font-size: 1.5rem; font-weight: 400; margin: 0; } h1 a { color: white; text-decoration: none; } h1 a:hover { text-decoration: underline; } .user-info { margin-left: auto; display: flex; align-items: center; gap: 1rem; } ================================================ FILE: src/WebhookClient/Components/Layout/UserMenu.razor ================================================ @using Microsoft.AspNetCore.Components.Authorization @using System.Web @inject NavigationManager Nav @context.User.Identity?.Name
    @code { private void LogIn() { var returnUrl = Nav.ToBaseRelativePath(Nav.Uri); Nav.NavigateTo($"login?returnUrl={HttpUtility.UrlEncode(returnUrl)}", forceLoad: true); } } ================================================ FILE: src/WebhookClient/Components/Layout/UserMenu.razor.css ================================================ button.action { font-size: 0.95rem; } ================================================ FILE: src/WebhookClient/Components/Pages/AddWebhook.razor ================================================ @page "/add-webhook" @using Microsoft.Extensions.Options @inject IOptions options @inject NavigationManager Nav @inject WebhooksClient WebhooksClient

    Register a new webhook

    This page registers the "OrderPaid" Webhook by sending a POST to the WebHooks API. Once the Webhook is set, you will be able to see new paid orders from the home page.

    Token:

    @if (!string.IsNullOrEmpty(message)) {

    @message

    } @code { string? token; string? message; protected override void OnInitialized() { token = options.Value.Token; } private async Task RegisterAsync() { if (string.IsNullOrEmpty(token)) { return; } message = null; var baseUrl = !string.IsNullOrEmpty(options.Value.SelfUrl) ? options.Value.SelfUrl : Nav.BaseUri; var granturl = $"{baseUrl}check"; var url = $"{baseUrl}webhook-received"; var payload = new WebhookSubscriptionRequest { Event = "OrderPaid", GrantUrl = granturl, Url = url, Token = token }; var response = await WebhooksClient.AddWebHookAsync(payload); if (response.IsSuccessStatusCode) { Nav.NavigateTo(""); } else { message = $"Registation was rejected with status {(int)response.StatusCode} {response.ReasonPhrase}"; } } } ================================================ FILE: src/WebhookClient/Components/Pages/Error.razor ================================================ @page "/Error" @using System.Diagnostics Error

    Error.

    An error occurred while processing your request.

    @if (ShowRequestId) {

    Request ID: @RequestId

    }

    Development Mode

    Swapping to Development environment will display more detailed information about the error that occurred.

    The Development environment shouldn't be enabled for deployed applications. It can result in displaying sensitive information from exceptions to end users. For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.

    @code{ [CascadingParameter] private HttpContext? HttpContext { get; set; } private string? RequestId { get; set; } private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); protected override void OnInitialized() => RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; } ================================================ FILE: src/WebhookClient/Components/Pages/Home/Home.razor ================================================ @page "/" @using Microsoft.AspNetCore.Components.Authorization Order management

    Registered webhooks

    Add webhook registration
    Log in to view or edit webhook registrations

    Webhook messages received (orders paid)

    ================================================ FILE: src/WebhookClient/Components/Pages/Home/Home.razor.css ================================================ h2 { margin: 2rem 0 1rem 0; } h2:first-child { margin-top: 0; } ================================================ FILE: src/WebhookClient/Components/Pages/Home/ReceivedMessages.razor ================================================ @inject HooksRepository HooksRepository @implements IDisposable @if (messages is null) {
    Loading...
    } else if (messages.Any()) { } else {
    None yet received

    Webhook messages will appear once a webhook is registered and an order transitions into "paid" status.

    } @code { private IQueryable? messages; private IDisposable? subscription; protected override async Task OnInitializedAsync() { subscription = HooksRepository.Subscribe(() => InvokeAsync(OnMessageReceivedAsync)); await RefreshDataAsync(); } private async Task RefreshDataAsync() => messages = (await HooksRepository.GetAll()).AsQueryable(); private async Task OnMessageReceivedAsync() { try { await RefreshDataAsync(); StateHasChanged(); } catch (Exception ex) { await DispatchExceptionAsync(ex); } } public void Dispose() { subscription?.Dispose(); } } ================================================ FILE: src/WebhookClient/Components/Pages/Home/RegisteredHooks.razor ================================================ @inject WebhooksClient WebhooksClient @if (webhooks is null) {
    Loading...
    } else if (webhooks.Any()) { } else {
    None registered
    } @code { IQueryable? webhooks; protected override async Task OnInitializedAsync() { webhooks = (await WebhooksClient.LoadWebhooks()).AsQueryable(); } } ================================================ FILE: src/WebhookClient/Components/Pages/LogIn.razor ================================================ @page "/login" @using Microsoft.AspNetCore.Authorization @attribute [Authorize] @inject NavigationManager Nav @code { [SupplyParameterFromQuery] public string? ReturnUrl { get; set; } protected override void OnInitialized() { var returnUrl = new Uri(ReturnUrl ?? "", UriKind.Relative); Nav.NavigateTo(returnUrl.ToString(), replace: true); } } ================================================ FILE: src/WebhookClient/Components/Routes.razor ================================================  ================================================ FILE: src/WebhookClient/Components/_Imports.razor ================================================ @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.QuickGrid @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop ================================================ FILE: src/WebhookClient/Endpoints/AuthenticationEndpoints.cs ================================================ using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; namespace eShop.WebhookClient.Endpoints; public static class AuthenticationEndpoints { public static IEndpointRouteBuilder MapAuthenticationEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/logout", async (HttpContext httpContext, IAntiforgery antiforgery) => { await antiforgery.ValidateRequestAsync(httpContext); await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); }); return app; } } ================================================ FILE: src/WebhookClient/Endpoints/WebhookEndpoints.cs ================================================ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace eShop.WebhookClient.Endpoints; public static class WebhookEndpoints { public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuilder app) { const string webhookCheckHeader = "X-eshop-whtoken"; var configuration = app.ServiceProvider.GetRequiredService(); bool.TryParse(configuration["ValidateToken"], out var validateToken); var tokenToValidate = configuration["WebhookClientOptions:Token"]; app.MapMethods("/check", [HttpMethods.Options], Results> ([FromHeader(Name = webhookCheckHeader)] string value, HttpResponse response) => { if (!validateToken || value == tokenToValidate) { if (!string.IsNullOrWhiteSpace(value)) { response.Headers.Append(webhookCheckHeader, value); } return TypedResults.Ok(); } return TypedResults.BadRequest("Invalid token"); }); app.MapPost("/webhook-received", async (WebhookData hook, HttpRequest request, ILogger logger, HooksRepository hooksRepository) => { var token = request.Headers[webhookCheckHeader]; logger.LogInformation("Received hook with token {Token}. My token is {MyToken}. Token validation is set to {ValidateToken}", token, tokenToValidate, validateToken); if (!validateToken || tokenToValidate == token) { logger.LogInformation("Received hook is going to be processed"); var newHook = new WebHookReceived() { Data = hook.Payload, When = hook.When, Token = token }; await hooksRepository.AddNew(newHook); logger.LogInformation("Received hook was processed."); return Results.Ok(newHook); } logger.LogInformation("Received hook is NOT processed - Bad Request returned."); return Results.BadRequest(); }); return app; } } ================================================ FILE: src/WebhookClient/Extensions/Extensions.cs ================================================ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; namespace eShop.WebhookClient.Extensions; public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddAuthenticationServices(); // Application services builder.Services.AddOptions().BindConfiguration(nameof(WebhookClientOptions)); builder.Services.AddSingleton(); // HTTP client registrations builder.Services.AddHttpClient(o => o.BaseAddress = new("http://webhooks-api")) .AddApiVersion(1.0) .AddAuthToken(); } public static void AddAuthenticationServices(this IHostApplicationBuilder builder) { var configuration = builder.Configuration; var services = builder.Services; var identityUrl = configuration.GetRequiredValue("IdentityUrl"); var callBackUrl = configuration.GetRequiredValue("CallBackUrl"); var sessionCookieLifetime = configuration.GetValue("SessionCookieLifetimeMinutes", 60); // Add Authentication services services.AddAuthorization(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(options => { options.ExpireTimeSpan = TimeSpan.FromMinutes(sessionCookieLifetime); // Must be distinct from WebApp's cookie name, otherwise the two sites will interfere // with each other when both are on localhost (yes, even when they are on different ports) options.Cookie.Name = ".AspNetCore.WebHooksClientIdentity"; }) .AddOpenIdConnect(options => { options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Authority = identityUrl.ToString(); options.SignedOutRedirectUri = callBackUrl.ToString(); options.ClientId = "webhooksclient"; options.ClientSecret = "secret"; options.ResponseType = "code"; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; options.RequireHttpsMetadata = false; options.Scope.Add("openid"); options.Scope.Add("webhooks"); }); services.AddScoped(); services.AddCascadingAuthenticationState(); } } ================================================ FILE: src/WebhookClient/GlobalUsings.cs ================================================ global using eShop.ServiceDefaults; global using eShop.WebhookClient.Components; global using eShop.WebhookClient.Endpoints; global using eShop.WebhookClient.Extensions; global using eShop.WebhookClient.Services; ================================================ FILE: src/WebhookClient/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.AddApplicationServices(); var app = builder.Build(); app.MapDefaultEndpoints(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseAntiforgery(); app.UseStaticFiles(); app.MapRazorComponents().AddInteractiveServerRenderMode(); app.MapAuthenticationEndpoints(); app.MapWebhookEndpoints(); app.Run(); ================================================ FILE: src/WebhookClient/Properties/launchSettings.json ================================================ { "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { "applicationUrl": "http://localhost:64178", "sslPort": 44365 } }, "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5062", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7260;http://localhost:5062", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/WebhookClient/Services/HooksRepository.cs ================================================ using System.Collections.Concurrent; namespace eShop.WebhookClient.Services; public class HooksRepository { private readonly ConcurrentQueue _data = new(); private readonly ConcurrentDictionary _onChangeSubscriptions = new(); public Task AddNew(WebHookReceived hook) { _data.Enqueue(hook); foreach (var subscription in _onChangeSubscriptions) { try { _ = subscription.Key.NotifyAsync(); } catch (Exception) { // It's the subscriber's responsibility to report/handle any exceptions // that occur during their callback } } return Task.CompletedTask; } public Task> GetAll() { return Task.FromResult(_data.AsEnumerable()); } public IDisposable Subscribe(Func callback) { var subscription = new OnChangeSubscription(callback, this); _onChangeSubscriptions.TryAdd(subscription, null); return subscription; } private class OnChangeSubscription(Func callback, HooksRepository owner) : IDisposable { public Task NotifyAsync() => callback(); public void Dispose() => owner._onChangeSubscriptions.Remove(this, out _); } } ================================================ FILE: src/WebhookClient/Services/WebHookReceived.cs ================================================ namespace eShop.WebhookClient.Services; public class WebHookReceived { public DateTime When { get; set; } public string? Data { get; set; } public string? Token { get; set; } } ================================================ FILE: src/WebhookClient/Services/WebHooksClient.cs ================================================ namespace eShop.WebhookClient.Services; public class WebhooksClient(HttpClient client) { public Task AddWebHookAsync(WebhookSubscriptionRequest payload) { return client.PostAsJsonAsync("/api/webhooks", payload); } public async Task> LoadWebhooks() { return await client.GetFromJsonAsync>("/api/webhooks") ?? []; } } ================================================ FILE: src/WebhookClient/Services/WebhookClientOptions.cs ================================================ namespace eShop.WebhookClient.Services; public class WebhookClientOptions { public string? Token { get; set; } public string? SelfUrl { get; set; } public bool ValidateToken { get; set; } } ================================================ FILE: src/WebhookClient/Services/WebhookData.cs ================================================ using System.Text.Json; namespace eShop.WebhookClient.Services; public class WebhookData { public DateTime When { get; set; } public string? Payload { get; set; } public string? Type { get; set; } } ================================================ FILE: src/WebhookClient/Services/WebhookResponse.cs ================================================ namespace eShop.WebhookClient.Services; public class WebhookResponse { public DateTime Date { get; set; } public string? DestUrl { get; set; } public string? Token { get; set; } } ================================================ FILE: src/WebhookClient/Services/WebhookSubscriptionRequest.cs ================================================ namespace eShop.WebhookClient.Services; public class WebhookSubscriptionRequest { public string? Url { get; set; } public string? Token { get; set; } public string? Event { get; set; } public string? GrantUrl { get; set; } } ================================================ FILE: src/WebhookClient/Services/WebhookType.cs ================================================ namespace eShop.WebhookClient.Services; public enum WebhookType { CatalogItemPriceChange = 1, OrderShipped = 2, OrderPaid = 3 } ================================================ FILE: src/WebhookClient/WebhookClient.csproj ================================================ net10.0 enable enable eShop.WebhookClient ================================================ FILE: src/WebhookClient/appsettings.Development.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } } ================================================ FILE: src/WebhookClient/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "WebhookClientOptions": { "Token": "6168DB8D-DC58-4094-AF24-483278923590" } } ================================================ FILE: src/WebhookClient/wwwroot/app.css ================================================ h1, h2, h3, h4, h5, h6 { font-weight: 400; } h2 { font-size: 1.3rem; } h2:first-child, h3:first-child { margin-top: 0; } p { line-height: 1.35rem; } h1:focus { outline: none; } .valid.modified:not([type=checkbox]) { outline: 1px solid #26b050; } .invalid { outline: 1px solid #e50000; } .validation-message { color: #e50000; } .blazor-error-boundary { background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; padding: 1rem 1rem 1rem 3.7rem; color: white; } .blazor-error-boundary::after { content: "An error has occurred." } .darker-border-checkbox.form-check-input { border-color: #929292; } button { font-size: 1rem; } button.action, a.action { background-color: #1a7dad; color: white; padding: 0.4rem 1.25rem; border: none; border-radius: 0.5rem; box-shadow: 0px 3px 4px #00000070; transition: all 0.1s cubic-bezier(.16,1.13,.57,.94); display: inline-block; text-decoration: none; cursor: pointer; font-size: 1.05em; } button.action:hover, a.action:hover { background-color: #23a0dc; } button.action:active, a.action:active { background-color: #00496c; transform: scale(0.98) translateY(1px); } .grid-placeholder { height: 5rem; max-width: 50rem; background-color: #e1e1e1; margin-bottom: 1rem; display: flex; align-items: center; justify-content: center; color: #555; border-radius: 0.5rem; } input[type=text] { font-size: 1.05rem; padding: 0.2rem 0.5rem; border-radius: 0.4rem; margin-left: 0.5rem; border: 2px solid #038ac7; } .error-message { color: red; background: #ffefef; padding: 0.4rem 1rem; border: 1px solid #ffb4b4; border-radius: 0.4rem; } .quickgrid { margin-bottom: 1rem; border-spacing: 0; } .quickgrid th { border-bottom: 1px solid gray; } .quickgrid tr:not(:last-child) td { border-bottom: 1px solid #e1e1e1; } .quickgrid td, .quickgrid th { padding-top: 0.4rem !important; padding-bottom: 0.4rem !important; } ================================================ FILE: src/Webhooks.API/Apis/WebHooksApi.cs ================================================ using System.Security.Claims; using Microsoft.AspNetCore.Http.HttpResults; using Webhooks.API.Extensions; namespace Webhooks.API; public static class WebHooksApi { public static RouteGroupBuilder MapWebHooksApiV1(this IEndpointRouteBuilder app) { var api = app.MapGroup("/api/webhooks").HasApiVersion(1.0); api.MapGet("/", async (WebhooksContext context, ClaimsPrincipal user) => { var userId = user.GetUserId(); var data = await context.Subscriptions.Where(s => s.UserId == userId).ToListAsync(); return TypedResults.Ok(data); }); api.MapGet("/{id:int}", async Task, NotFound>> ( WebhooksContext context, ClaimsPrincipal user, int id) => { var userId = user.GetUserId(); var subscription = await context.Subscriptions .SingleOrDefaultAsync(s => s.Id == id && s.UserId == userId); if (subscription != null) { return TypedResults.Ok(subscription); } return TypedResults.NotFound($"Subscriptions {id} not found"); }); api.MapPost("/", async Task>> ( WebhookSubscriptionRequest request, IGrantUrlTesterService grantUrlTester, WebhooksContext context, ClaimsPrincipal user) => { var grantOk = await grantUrlTester.TestGrantUrl(request.Url, request.GrantUrl, request.Token ?? string.Empty); if (grantOk) { var subscription = new WebhookSubscription() { Date = DateTime.UtcNow, DestUrl = request.Url, Token = request.Token, Type = Enum.Parse(request.Event, ignoreCase: true), UserId = user.GetUserId() }; context.Add(subscription); await context.SaveChangesAsync(); return TypedResults.Created($"/api/webhooks/{subscription.Id}"); } else { return TypedResults.BadRequest($"Invalid grant URL: {request.GrantUrl}"); } }) .ValidateWebhookSubscriptionRequest(); api.MapDelete("/{id:int}", async Task>> ( WebhooksContext context, ClaimsPrincipal user, int id) => { var userId = user.GetUserId(); var subscription = await context.Subscriptions.SingleOrDefaultAsync(s => s.Id == id && s.UserId == userId); if (subscription != null) { context.Remove(subscription); await context.SaveChangesAsync(); return TypedResults.Accepted($"/api/webhooks/{subscription.Id}"); } return TypedResults.NotFound($"Subscriptions {id} not found"); }); return api; } } ================================================ FILE: src/Webhooks.API/Exceptions/WebhooksDomainException.cs ================================================ namespace Webhooks.API.Exceptions; public class WebhooksDomainException : Exception { } ================================================ FILE: src/Webhooks.API/Extensions/Extensions.cs ================================================ internal static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddDefaultAuthentication(); builder.AddRabbitMqEventBus("eventbus") .AddEventBusSubscriptions(); builder.AddNpgsqlDbContext("webhooksdb"); builder.Services.AddMigration(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); } private static void AddEventBusSubscriptions(this IEventBusBuilder eventBus) { eventBus.AddSubscription(); eventBus.AddSubscription(); eventBus.AddSubscription(); } } ================================================ FILE: src/Webhooks.API/Extensions/RouteHandlerBuilderExtensions.cs ================================================ namespace Webhooks.API.Extensions; public static class RouteHandlerBuilderExtensions { public static RouteHandlerBuilder ValidateWebhookSubscriptionRequest(this RouteHandlerBuilder routeHandlerBuilder) { return routeHandlerBuilder.AddEndpointFilter(async (context, next) => { var webhookSubscriptionRequest = context.Arguments.OfType().SingleOrDefault(); if (webhookSubscriptionRequest == null) { return TypedResults.BadRequest("No WebhookSubscriptionRequest found."); } var validationResults = webhookSubscriptionRequest.Validate(new ValidationContext(webhookSubscriptionRequest)); if (validationResults.Any()) { return TypedResults.ValidationProblem(validationResults.ToErrors()); } return await next(context); }); } private static Dictionary ToErrors(this IEnumerable validationResults) { Dictionary errors = []; foreach (var validationResult in validationResults) { var propertyNames = validationResult.MemberNames.Any() ? validationResult.MemberNames : [string.Empty]; foreach (string propertyName in propertyNames) { if (errors.TryGetValue(propertyName, out var value)) { errors[propertyName] = [..value, validationResult.ErrorMessage]; } else { errors.Add(propertyName, [validationResult.ErrorMessage]); } } } return errors; } } ================================================ FILE: src/Webhooks.API/GlobalUsings.cs ================================================ global using System.ComponentModel.DataAnnotations; global using System.Text; global using System.Text.Json; global using Microsoft.EntityFrameworkCore; global using eShop.EventBus.Abstractions; global using eShop.EventBus.Events; global using eShop.ServiceDefaults; global using Webhooks.API.Infrastructure; global using Webhooks.API.IntegrationEvents; global using Webhooks.API.Model; global using Webhooks.API.Services; global using Webhooks.API; ================================================ FILE: src/Webhooks.API/Infrastructure/WebhooksContext.cs ================================================ namespace Webhooks.API.Infrastructure; /// /// Add migrations using the following command inside the 'Webhooks.API' project directory: /// /// dotnet ef migrations add [migration-name] /// public class WebhooksContext(DbContextOptions options) : DbContext(options) { public DbSet Subscriptions { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(eb => { eb.HasIndex(s => s.UserId); eb.HasIndex(s => s.Type); }); } } ================================================ FILE: src/Webhooks.API/IntegrationEvents/OrderStatusChangedToPaidIntegrationEvent.cs ================================================ namespace Webhooks.API.IntegrationEvents; public record OrderStatusChangedToPaidIntegrationEvent(int OrderId, IEnumerable OrderStockItems) : IntegrationEvent; ================================================ FILE: src/Webhooks.API/IntegrationEvents/OrderStatusChangedToPaidIntegrationEventHandler.cs ================================================ namespace Webhooks.API.IntegrationEvents; public class OrderStatusChangedToPaidIntegrationEventHandler( IWebhooksRetriever retriever, IWebhooksSender sender, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToPaidIntegrationEvent @event) { var subscriptions = await retriever.GetSubscriptionsOfType(WebhookType.OrderPaid); logger.LogInformation("Received OrderStatusChangedToShippedIntegrationEvent and got {SubscriptionsCount} subscriptions to process", subscriptions.Count()); var whook = new WebhookData(WebhookType.OrderPaid, @event); await sender.SendAll(subscriptions, whook); } } ================================================ FILE: src/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEvent.cs ================================================ namespace Webhooks.API.IntegrationEvents; public record OrderStatusChangedToShippedIntegrationEvent(int OrderId, string OrderStatus, string BuyerName) : IntegrationEvent; ================================================ FILE: src/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEventHandler.cs ================================================ namespace Webhooks.API.IntegrationEvents; public class OrderStatusChangedToShippedIntegrationEventHandler( IWebhooksRetriever retriever, IWebhooksSender sender, ILogger logger) : IIntegrationEventHandler { public async Task Handle(OrderStatusChangedToShippedIntegrationEvent @event) { var subscriptions = await retriever.GetSubscriptionsOfType(WebhookType.OrderShipped); logger.LogInformation("Received OrderStatusChangedToShippedIntegrationEvent and got {SubscriptionCount} subscriptions to process", subscriptions.Count()); var whook = new WebhookData(WebhookType.OrderShipped, @event); await sender.SendAll(subscriptions, whook); } } ================================================ FILE: src/Webhooks.API/IntegrationEvents/OrderStockItem.cs ================================================ namespace Webhooks.API.IntegrationEvents; public record OrderStockItem(int ProductId, int Units); ================================================ FILE: src/Webhooks.API/IntegrationEvents/ProductPriceChangedIntegrationEvent.cs ================================================ namespace Webhooks.API.IntegrationEvents; public record ProductPriceChangedIntegrationEvent(int ProductId, decimal NewPrice, decimal OldPrice) : IntegrationEvent; ================================================ FILE: src/Webhooks.API/IntegrationEvents/ProductPriceChangedIntegrationEventHandler.cs ================================================ namespace Webhooks.API.IntegrationEvents; public class ProductPriceChangedIntegrationEventHandler : IIntegrationEventHandler { public Task Handle(ProductPriceChangedIntegrationEvent @event) { return Task.CompletedTask; } } ================================================ FILE: src/Webhooks.API/Migrations/20230925222606_Initial.Designer.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Webhooks.API.Infrastructure; #nullable disable namespace Webhooks.API.Migrations { [DbContext(typeof(WebhooksContext))] [Migration("20230925222606_Initial")] partial class Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rc.1.23419.6") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Webhooks.API.Model.WebhookSubscription", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Date") .HasColumnType("timestamp with time zone"); b.Property("DestUrl") .HasColumnType("text"); b.Property("Token") .HasColumnType("text"); b.Property("Type") .HasColumnType("integer"); b.Property("UserId") .HasColumnType("text"); b.HasKey("Id"); b.ToTable("Subscriptions"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Webhooks.API/Migrations/20230925222606_Initial.cs ================================================ using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable namespace Webhooks.API.Migrations { /// public partial class Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Subscriptions", columns: table => new { Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), Type = table.Column(type: "integer", nullable: false), Date = table.Column(type: "timestamp with time zone", nullable: false), DestUrl = table.Column(type: "text", nullable: true), Token = table.Column(type: "text", nullable: true), UserId = table.Column(type: "text", nullable: true) }, constraints: table => { table.PrimaryKey("PK_Subscriptions", x => x.Id); }); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "Subscriptions"); } } } ================================================ FILE: src/Webhooks.API/Migrations/WebhooksContextModelSnapshot.cs ================================================ // using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Webhooks.API.Infrastructure; #nullable disable namespace Webhooks.API.Migrations { [DbContext(typeof(WebhooksContext))] partial class WebhooksContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.0-rc.2.23480.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Webhooks.API.Model.WebhookSubscription", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Date") .HasColumnType("timestamp with time zone"); b.Property("DestUrl") .IsRequired() .HasColumnType("text"); b.Property("Token") .HasColumnType("text"); b.Property("Type") .HasColumnType("integer"); b.Property("UserId") .IsRequired() .HasColumnType("text"); b.HasKey("Id"); b.HasIndex("Type"); b.HasIndex("UserId"); b.ToTable("Subscriptions"); }); #pragma warning restore 612, 618 } } } ================================================ FILE: src/Webhooks.API/Model/WebhookData.cs ================================================ namespace Webhooks.API.Model; public class WebhookData { public DateTime When { get; } public string Payload { get; } public string Type { get; } public WebhookData(WebhookType hookType, object data) { When = DateTime.UtcNow; Type = hookType.ToString(); Payload = JsonSerializer.Serialize(data); } } ================================================ FILE: src/Webhooks.API/Model/WebhookSubscription.cs ================================================ namespace Webhooks.API.Model; public class WebhookSubscription { public int Id { get; set; } public WebhookType Type { get; set; } public DateTime Date { get; set; } [Required] public string DestUrl { get; set; } public string Token { get; set; } [Required] public string UserId { get; set; } } ================================================ FILE: src/Webhooks.API/Model/WebhookSubscriptionRequest.cs ================================================ namespace Webhooks.API.Model; public class WebhookSubscriptionRequest : IValidatableObject { public string Url { get; set; } public string Token { get; set; } public string Event { get; set; } public string GrantUrl { get; set; } public IEnumerable Validate(ValidationContext validationContext) { if (!Uri.IsWellFormedUriString(GrantUrl, UriKind.Absolute)) { yield return new ValidationResult("GrantUrl is not valid", new[] { nameof(GrantUrl) }); } if (!Uri.IsWellFormedUriString(Url, UriKind.Absolute)) { yield return new ValidationResult("Url is not valid", new[] { nameof(Url) }); } var isOk = Enum.TryParse(Event, ignoreCase: true, result: out WebhookType whtype); if (!isOk) { yield return new ValidationResult($"{Event} is invalid event name", new[] { nameof(Event) }); } } } ================================================ FILE: src/Webhooks.API/Model/WebhookType.cs ================================================ namespace Webhooks.API.Model; public enum WebhookType { CatalogItemPriceChange = 1, OrderShipped = 2, OrderPaid = 3 } ================================================ FILE: src/Webhooks.API/Program.cs ================================================ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddApplicationServices(); var withApiVersioning = builder.Services.AddApiVersioning(); builder.AddDefaultOpenApi(withApiVersioning); var app = builder.Build(); app.MapDefaultEndpoints(); var webHooks = app.NewVersionedApi("Web Hooks"); webHooks.MapWebHooksApiV1() .RequireAuthorization(); app.UseDefaultOpenApi(); app.Run(); ================================================ FILE: src/Webhooks.API/Properties/launchSettings.json ================================================ { "profiles": { "http": { "commandName": "Project", "launchBrowser": true, "applicationUrl": "http://localhost:5227", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/Webhooks.API/Services/GrantUrlTesterService.cs ================================================ namespace Webhooks.API.Services; class GrantUrlTesterService(IHttpClientFactory factory, ILogger logger) : IGrantUrlTesterService { public async Task TestGrantUrl(string urlHook, string url, string token) { if (!CheckSameOrigin(urlHook, url)) { logger.LogWarning("Url of the hook ({UrlHook} and the grant url ({Url} do not belong to same origin)", urlHook, url); return false; } var client = factory.CreateClient(); var msg = new HttpRequestMessage(HttpMethod.Options, url); msg.Headers.Add("X-eshop-whtoken", token); logger.LogInformation("Sending the OPTIONS message to {Url} with token \"{Token}\"", url, token ?? string.Empty); try { var response = await client.SendAsync(msg); var tokenReceived = response.Headers.TryGetValues("X-eshop-whtoken", out var tokenValues) ? tokenValues.FirstOrDefault() : null; var tokenExpected = string.IsNullOrWhiteSpace(token) ? null : token; logger.LogInformation("Response code is {StatusCode} for url {Url} and token in header was {TokenReceived} (expected token was {TokenExpected})", response.StatusCode, url, tokenReceived, tokenExpected); return response.IsSuccessStatusCode && tokenReceived == tokenExpected; } catch (Exception ex) { logger.LogWarning("Exception {TypeName} when sending OPTIONS request. Url can't be granted.", ex.GetType().Name); return false; } } private static bool CheckSameOrigin(string urlHook, string url) { var firstUrl = new Uri(urlHook, UriKind.Absolute); var secondUrl = new Uri(url, UriKind.Absolute); return firstUrl.Scheme == secondUrl.Scheme && firstUrl.Port == secondUrl.Port && firstUrl.Host == secondUrl.Host; } } ================================================ FILE: src/Webhooks.API/Services/IGrantUrlTesterService.cs ================================================ namespace Webhooks.API.Services; public interface IGrantUrlTesterService { Task TestGrantUrl(string urlHook, string url, string token); } ================================================ FILE: src/Webhooks.API/Services/IWebhooksRetriever.cs ================================================ namespace Webhooks.API.Services; public interface IWebhooksRetriever { Task> GetSubscriptionsOfType(WebhookType type); } ================================================ FILE: src/Webhooks.API/Services/IWebhooksSender.cs ================================================ namespace Webhooks.API.Services; public interface IWebhooksSender { Task SendAll(IEnumerable receivers, WebhookData data); } ================================================ FILE: src/Webhooks.API/Services/WebhooksRetriever.cs ================================================ namespace Webhooks.API.Services; public class WebhooksRetriever(WebhooksContext db) : IWebhooksRetriever { public async Task> GetSubscriptionsOfType(WebhookType type) { return await db.Subscriptions.Where(s => s.Type == type).ToListAsync(); } } ================================================ FILE: src/Webhooks.API/Services/WebhooksSender.cs ================================================ namespace Webhooks.API.Services; public class WebhooksSender(IHttpClientFactory httpClientFactory, ILogger logger) : IWebhooksSender { public async Task SendAll(IEnumerable receivers, WebhookData data) { var client = httpClientFactory.CreateClient(); var json = JsonSerializer.Serialize(data); var tasks = receivers.Select(r => OnSendData(r, json, client)); await Task.WhenAll(tasks); } private Task OnSendData(WebhookSubscription subs, string jsonData, HttpClient client) { var request = new HttpRequestMessage() { RequestUri = new Uri(subs.DestUrl, UriKind.Absolute), Method = HttpMethod.Post, Content = new StringContent(jsonData, Encoding.UTF8, "application/json") }; if (!string.IsNullOrWhiteSpace(subs.Token)) { request.Headers.Add("X-eshop-whtoken", subs.Token); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("Sending hook to {DestUrl} of type {Type}", subs.DestUrl, subs.Type); } return client.SendAsync(request); } } ================================================ FILE: src/Webhooks.API/Webhooks.API.csproj ================================================  net10.0 all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: src/Webhooks.API/appsettings.Development.json ================================================ { "Logging": { "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } }, "ConnectionStrings": { "WebHooksDB": "Host=localhost;Database=WebHooksDB;Username=postgres;Password=yourWeak(!)Password" } } ================================================ FILE: src/Webhooks.API/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "OpenApi": { "Endpoint": { "Name": "Webhooks.API V1" }, "Document": { "Description": "The Webhooks Microservice HTTP API. This is a simple webhooks CRUD registration entrypoint", "Title": "eShop - Webhooks HTTP API", "Version": "v1" }, "Auth": { "ClientId": "webhooksswaggerui", "AppName": "WebHooks Service Swagger UI" } }, "ConnectionStrings": { "EventBus": "amqp://localhost" }, "EventBus": { "SubscriptionClientName": "Webhooks" }, "Identity": { "Url": "http://localhost:5223", "Audience": "webhooks", "Scopes": { "webhooks": "Webhooks API" } }, "UseCustomizationData": false } ================================================ FILE: src/eShop.AppHost/Extensions.cs ================================================ using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Yarp; using Aspire.Hosting.Yarp.Transforms; using Yarp.ReverseProxy.Configuration; namespace eShop.AppHost; internal enum OpenAITarget { OpenAI, AzureOpenAI, AzureOpenAIExisting, AzureOpenAIExistingWithKey } internal static class Extensions { /// /// Adds a hook to set the ASPNETCORE_FORWARDEDHEADERS_ENABLED environment variable to true for all projects in the application. /// public static IDistributedApplicationBuilder AddForwardedHeaders(this IDistributedApplicationBuilder builder) { builder.Services.TryAddEventingSubscriber(); return builder; } private class AddForwardHeadersSubscriber : IDistributedApplicationEventingSubscriber { public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) { eventing.Subscribe((@event, ct) => { foreach (var p in @event.Model.GetProjectResources()) { p.Annotations.Add(new EnvironmentCallbackAnnotation(context => { context.EnvironmentVariables["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true"; })); } return Task.CompletedTask; }); return Task.CompletedTask; } } /// /// Configures eShop projects to use OpenAI for text embedding and chat. /// public static IDistributedApplicationBuilder AddOpenAI(this IDistributedApplicationBuilder builder, IResourceBuilder catalogApi, IResourceBuilder webApp, OpenAITarget openAITarget) { const string openAIName = "openai"; const string textEmbeddingName = "textEmbeddingModel"; const string textEmbeddingModelName = "text-embedding-3-small"; const string chatName = "chatModel"; const string chatModelName = "gpt-4.1-mini"; if (openAITarget != OpenAITarget.AzureOpenAI) { #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IResourceBuilder? endpoint = null; if (openAITarget != OpenAITarget.OpenAI) { endpoint = builder.AddParameter("OpenAIEndpointParameter") .WithDescription("The Azure OpenAI endpoint to use, e.g. https://.openai.azure.com/") .WithCustomInput(p => new() { Name = "OpenAIEndpointParameter", Label = "Azure OpenAI Endpoint", InputType = InputType.Text, Value = "https://.openai.azure.com/", }); } IResourceBuilder? key = null; if (openAITarget is OpenAITarget.OpenAI or OpenAITarget.AzureOpenAIExistingWithKey) { key = builder.AddParameter("OpenAIKeyParameter", secret: true) .WithDescription("The OpenAI API key to use.") .WithCustomInput(p => new() { Name = "OpenAIKeyParameter", Label = "API Key", InputType = InputType.SecretText }); } var chatModel = builder.AddParameter("ChatModelParameter") .WithDescription("The chat model to use.") .WithCustomInput(p => new() { Name = "ChatModelParameter", Label = "Chat Model", InputType = InputType.Text, Value = chatModelName, }); var embeddingModel = builder.AddParameter("EmbeddingModelParameter") .WithDescription("The embedding model to use.") .WithCustomInput(p => new() { Name = "EmbeddingModelParameter", Label = "Text Embedding Model", InputType = InputType.Text, Value = textEmbeddingModelName, }); #pragma warning restore ASPIREINTERACTION001 var openAIConnectionBuilder = new ReferenceExpressionBuilder(); if (endpoint is not null) { openAIConnectionBuilder.Append($"Endpoint={endpoint}"); } if (key is not null) { openAIConnectionBuilder.Append($";Key={key}"); } var openAIConnectionString = openAIConnectionBuilder.Build(); catalogApi.WithReference(builder.AddConnectionString(textEmbeddingName, cs => { cs.Append($"{openAIConnectionString};Deployment={embeddingModel}"); })); webApp.WithReference(builder.AddConnectionString(chatName, cs => { cs.Append($"{openAIConnectionString};Deployment={chatModel}"); })); } else { var openAI = builder.AddAzureOpenAI(openAIName); var chat = openAI.AddDeployment(chatName, chatModelName, "2025-04-14") .WithProperties(d => { d.DeploymentName = chatModelName; d.SkuName = "GlobalStandard"; d.SkuCapacity = 50; }); var textEmbedding = openAI.AddDeployment(textEmbeddingName, textEmbeddingModelName, "1") .WithProperties(d => { d.DeploymentName = textEmbeddingModelName; d.SkuCapacity = 20; // 20k tokens per minute are needed to seed the initial embeddings }); catalogApi.WithReference(textEmbedding); webApp.WithReference(chat); } return builder; } /// /// Configures eShop projects to use Ollama for text embedding and chat. /// public static IDistributedApplicationBuilder AddOllama(this IDistributedApplicationBuilder builder, IResourceBuilder catalogApi, IResourceBuilder webApp) { var ollama = builder.AddOllama("ollama") .WithDataVolume() .WithGPUSupport() .WithOpenWebUI(); var embeddings = ollama.AddModel("embedding", "all-minilm"); var chat = ollama.AddModel("chat", "llama3.1"); catalogApi.WithReference(embeddings) .WithEnvironment("OllamaEnabled", "true") .WaitFor(embeddings); webApp.WithReference(chat) .WithEnvironment("OllamaEnabled", "true") .WaitFor(chat); return builder; } public static IResourceBuilder ConfigureMobileBffRoutes(this IResourceBuilder builder, IResourceBuilder catalogApi, IResourceBuilder orderingApi, IResourceBuilder identityApi) { return builder.WithConfiguration(yarp => { var catalogCluster = yarp.AddCluster(catalogApi); yarp.AddRoute("/catalog-api/api/catalog/items", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/by", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/{id}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/by/{name}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/withsemanticrelevance/{text}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/withsemanticrelevance", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/type/{typeId}/brand/{brandId?}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/type/all/brand/{brandId?}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/catalogTypes", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/catalogBrands", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); yarp.AddRoute("/catalog-api/api/catalog/items/{id}/pic", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]) .WithTransformPathRemovePrefix("/catalog-api"); // Generic catalog catch-all route yarp.AddRoute("/api/catalog/{*any}", catalogCluster) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1", "2.0"], Mode = QueryParameterMatchMode.Exact }]); // Ordering routes yarp.AddRoute("/api/orders/{*any}", orderingApi.GetEndpoint("http")) .WithMatchRouteQueryParameter([new() { Name = "api-version", Values = ["1.0", "1"], Mode = QueryParameterMatchMode.Exact }]); // Identity routes yarp.AddRoute("/identity/{*any}", identityApi.GetEndpoint("http")) .WithTransformPathRemovePrefix("/identity"); }); } } ================================================ FILE: src/eShop.AppHost/Program.cs ================================================ using eShop.AppHost; var builder = DistributedApplication.CreateBuilder(args); builder.AddForwardedHeaders(); var redis = builder.AddRedis("redis"); var rabbitMq = builder.AddRabbitMQ("eventbus") .WithLifetime(ContainerLifetime.Persistent); var postgres = builder.AddPostgres("postgres") .WithImage("ankane/pgvector") .WithImageTag("latest") .WithLifetime(ContainerLifetime.Persistent); var catalogDb = postgres.AddDatabase("catalogdb"); var identityDb = postgres.AddDatabase("identitydb"); var orderDb = postgres.AddDatabase("orderingdb"); var webhooksDb = postgres.AddDatabase("webhooksdb"); var launchProfileName = ShouldUseHttpForEndpoints() ? "http" : "https"; // Services var identityApi = builder.AddProject("identity-api", launchProfileName) .WithExternalHttpEndpoints() .WithReference(identityDb); var identityEndpoint = identityApi.GetEndpoint(launchProfileName); var basketApi = builder.AddProject("basket-api") .WithReference(redis) .WithReference(rabbitMq).WaitFor(rabbitMq) .WithEnvironment("Identity__Url", identityEndpoint); redis.WithParentRelationship(basketApi); var catalogApi = builder.AddProject("catalog-api") .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(catalogDb); var orderingApi = builder.AddProject("ordering-api") .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(orderDb).WaitFor(orderDb) .WithHttpHealthCheck("/health") .WithEnvironment("Identity__Url", identityEndpoint); builder.AddProject("order-processor") .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(orderDb) .WaitFor(orderingApi); // wait for the orderingApi to be ready because that contains the EF migrations builder.AddProject("payment-processor") .WithReference(rabbitMq).WaitFor(rabbitMq); var webHooksApi = builder.AddProject("webhooks-api") .WithReference(rabbitMq).WaitFor(rabbitMq) .WithReference(webhooksDb) .WithEnvironment("Identity__Url", identityEndpoint); // Reverse proxies builder.AddYarp("mobile-bff") .WithExternalHttpEndpoints() .ConfigureMobileBffRoutes(catalogApi, orderingApi, identityApi); // Apps var webhooksClient = builder.AddProject("webhooksclient", launchProfileName) .WithReference(webHooksApi) .WithEnvironment("IdentityUrl", identityEndpoint); var webApp = builder.AddProject("webapp", launchProfileName) .WithExternalHttpEndpoints() .WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online Store ({u.Endpoint?.EndpointName})")) .WithReference(basketApi) .WithReference(catalogApi) .WithReference(orderingApi) .WithReference(rabbitMq).WaitFor(rabbitMq) .WithEnvironment("IdentityUrl", identityEndpoint); // set to true if you want to use OpenAI bool useOpenAI = false; if (useOpenAI) { builder.AddOpenAI(catalogApi, webApp, OpenAITarget.OpenAI); // set to AzureOpenAI if you want to use Azure OpenAI } bool useOllama = false; if (useOllama) { builder.AddOllama(catalogApi, webApp); } // Wire up the callback urls (self referencing) webApp.WithEnvironment("CallBackUrl", webApp.GetEndpoint(launchProfileName)); webhooksClient.WithEnvironment("CallBackUrl", webhooksClient.GetEndpoint(launchProfileName)); // Identity has a reference to all of the apps for callback urls, this is a cyclic reference identityApi.WithEnvironment("BasketApiClient", basketApi.GetEndpoint("http")) .WithEnvironment("OrderingApiClient", orderingApi.GetEndpoint("http")) .WithEnvironment("WebhooksApiClient", webHooksApi.GetEndpoint("http")) .WithEnvironment("WebhooksWebClient", webhooksClient.GetEndpoint(launchProfileName)) .WithEnvironment("WebAppClient", webApp.GetEndpoint(launchProfileName)); builder.Build().Run(); // For test use only. // Looks for an environment variable that forces the use of HTTP for all the endpoints. We // are doing this for ease of running the Playwright tests in CI. static bool ShouldUseHttpForEndpoints() { const string EnvVarName = "ESHOP_USE_HTTP_ENDPOINTS"; var envValue = Environment.GetEnvironmentVariable(EnvVarName); // Attempt to parse the environment variable value; return true if it's exactly "1". return int.TryParse(envValue, out int result) && result == 1; } ================================================ FILE: src/eShop.AppHost/Properties/launchSettings.json ================================================ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:19888;http://localhost:18848", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18076", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:19076" } } } } ================================================ FILE: src/eShop.AppHost/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } }, "ConnectionStrings": { //"OpenAi": "Endpoint=xxxx;Key=xxxx" } } ================================================ FILE: src/eShop.AppHost/eShop.AppHost.csproj ================================================ Exe net10.0 enable false b99dbce4-17d4-41d2-858a-2b0529d60bb8 ================================================ FILE: src/eShop.ServiceDefaults/AuthenticationExtensions.cs ================================================ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.JsonWebTokens; namespace eShop.ServiceDefaults; public static class AuthenticationExtensions { public static IServiceCollection AddDefaultAuthentication(this IHostApplicationBuilder builder) { var services = builder.Services; var configuration = builder.Configuration; // { // "Identity": { // "Url": "http://identity", // "Audience": "basket" // } // } var identitySection = configuration.GetSection("Identity"); if (!identitySection.Exists()) { // No identity section, so no authentication return services; } // prevent from mapping "sub" claim to nameidentifier. JsonWebTokenHandler.DefaultInboundClaimTypeMap.Remove("sub"); services.AddAuthentication().AddJwtBearer(options => { var identityUrl = identitySection.GetRequiredValue("Url"); var audience = identitySection.GetRequiredValue("Audience"); options.Authority = identityUrl; options.RequireHttpsMetadata = false; options.Audience = audience; #if DEBUG //Needed if using Android Emulator Locally. See https://learn.microsoft.com/en-us/dotnet/maui/data-cloud/local-web-services?view=net-maui-8.0#android options.TokenValidationParameters.ValidIssuers = [identityUrl, "https://10.0.2.2:5243"]; #else options.TokenValidationParameters.ValidIssuers = [identityUrl]; #endif options.TokenValidationParameters.ValidateAudience = false; }); services.AddAuthorization(); return services; } } ================================================ FILE: src/eShop.ServiceDefaults/ClaimsPrincipalExtensions.cs ================================================ using System.Security.Claims; namespace eShop.ServiceDefaults; public static class ClaimsPrincipalExtensions { public static string? GetUserId(this ClaimsPrincipal principal) => principal.FindFirst("sub")?.Value; public static string? GetUserName(this ClaimsPrincipal principal) => principal.FindFirst(x => x.Type == ClaimTypes.Name)?.Value; } ================================================ FILE: src/eShop.ServiceDefaults/ConfigurationExtensions.cs ================================================ namespace Microsoft.Extensions.Configuration; public static class ConfigurationExtensions { public static string GetRequiredValue(this IConfiguration configuration, string name) => configuration[name] ?? throw new InvalidOperationException($"Configuration missing value for: {(configuration is IConfigurationSection s ? s.Path + ":" + name : name)}"); } ================================================ FILE: src/eShop.ServiceDefaults/Extensions.cs ================================================ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; namespace eShop.ServiceDefaults; public static partial class Extensions { public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { builder.AddBasicServiceDefaults(); builder.Services.AddServiceDiscovery(); builder.Services.ConfigureHttpClientDefaults(http => { // Turn on resilience by default http.AddStandardResilienceHandler(); // Turn on service discovery by default http.AddServiceDiscovery(); }); return builder; } /// /// Adds the services except for making outgoing HTTP calls. /// /// /// This allows for things like Polly to be trimmed out of the app if it isn't used. /// public static IHostApplicationBuilder AddBasicServiceDefaults(this IHostApplicationBuilder builder) { // Default health checks assume the event bus and self health checks builder.AddDefaultHealthChecks(); builder.ConfigureOpenTelemetry(); return builder; } public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { builder.Logging.AddOpenTelemetry(logging => { logging.IncludeFormattedMessage = true; logging.IncludeScopes = true; }); builder.Services.AddOpenTelemetry() .WithMetrics(metrics => { metrics.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddMeter("Experimental.Microsoft.Extensions.AI"); }) .WithTracing(tracing => { if (builder.Environment.IsDevelopment()) { // We want to view all traces in development tracing.SetSampler(new AlwaysOnSampler()); } tracing.AddAspNetCoreInstrumentation() .AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() .AddSource("Experimental.Microsoft.Extensions.AI"); }); builder.AddOpenTelemetryExporters(); return builder; } private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); if (useOtlpExporter) { builder.Services.Configure(logging => logging.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } return builder; } public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) { builder.Services.AddHealthChecks() // Add a default liveness check to ensure app is responsive .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); return builder; } public static WebApplication MapDefaultEndpoints(this WebApplication app) { // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) // app.MapPrometheusScrapingEndpoint(); // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting app.MapHealthChecks("/health"); // Only health checks tagged with the "live" tag must pass for app to be considered alive app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); } return app; } } ================================================ FILE: src/eShop.ServiceDefaults/HttpClientExtensions.cs ================================================ using Microsoft.AspNetCore.Http; using System.Net.Http.Headers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.AspNetCore.Authentication; namespace eShop.ServiceDefaults; public static class HttpClientExtensions { public static IHttpClientBuilder AddAuthToken(this IHttpClientBuilder builder) { builder.Services.AddHttpContextAccessor(); builder.Services.TryAddTransient(); builder.AddHttpMessageHandler(); return builder; } private class HttpClientAuthorizationDelegatingHandler : DelegatingHandler { private readonly IHttpContextAccessor _httpContextAccessor; public HttpClientAuthorizationDelegatingHandler(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public HttpClientAuthorizationDelegatingHandler(IHttpContextAccessor httpContextAccessor, HttpMessageHandler innerHandler) : base(innerHandler) { _httpContextAccessor = httpContextAccessor; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (_httpContextAccessor.HttpContext is HttpContext context) { var accessToken = await context.GetTokenAsync("access_token"); if (accessToken is not null) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); } } return await base.SendAsync(request, cancellationToken); } } } ================================================ FILE: src/eShop.ServiceDefaults/OpenApi.Extensions.cs ================================================ using Asp.Versioning; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Scalar.AspNetCore; namespace eShop.ServiceDefaults; public static partial class Extensions { public static IApplicationBuilder UseDefaultOpenApi(this WebApplication app) { var configuration = app.Configuration; var openApiSection = configuration.GetSection("OpenApi"); if (!openApiSection.Exists()) { return app; } app.MapOpenApi(); if (app.Environment.IsDevelopment()) { app.MapScalarApiReference(options => { // Disable default fonts to avoid download unnecessary fonts options.DefaultFonts = false; }); app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription(); } return app; } public static IHostApplicationBuilder AddDefaultOpenApi( this IHostApplicationBuilder builder, IApiVersioningBuilder? apiVersioning = default) { var openApi = builder.Configuration.GetSection("OpenApi"); var identitySection = builder.Configuration.GetSection("Identity"); var scopes = identitySection.Exists() ? identitySection.GetRequiredSection("Scopes").GetChildren().ToDictionary(p => p.Key, p => p.Value) : new Dictionary(); if (!openApi.Exists()) { return builder; } if (apiVersioning is not null) { // the default format will just be ApiVersion.ToString(); for example, 1.0. // this will format the version as "'v'major[.minor][-status]" var versioned = apiVersioning.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV"); string[] versions = ["v1", "v2"]; foreach (var description in versions) { builder.Services.AddOpenApi(description, options => { options.ApplyApiVersionInfo(openApi.GetRequiredValue("Document:Title"), openApi.GetRequiredValue("Document:Description")); options.ApplyAuthorizationChecks([.. scopes.Keys]); options.ApplySecuritySchemeDefinitions(); options.ApplyOperationDeprecatedStatus(); options.ApplyApiVersionDescription(); }); } } return builder; } } ================================================ FILE: src/eShop.ServiceDefaults/OpenApiOptionsExtensions.cs ================================================ using System.Text; using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using Microsoft.OpenApi; using System.Text.Json.Nodes; namespace eShop.ServiceDefaults; internal static class OpenApiOptionsExtensions { public static OpenApiOptions ApplyApiVersionInfo(this OpenApiOptions options, string title, string description) { options.AddDocumentTransformer((document, context, cancellationToken) => { var versionedDescriptionProvider = context.ApplicationServices.GetService(); var apiDescription = versionedDescriptionProvider?.ApiVersionDescriptions .SingleOrDefault(description => description.GroupName == context.DocumentName); if (apiDescription is null) { return Task.CompletedTask; } document.Info.Version = apiDescription.ApiVersion.ToString(); document.Info.Title = title; document.Info.Description = BuildDescription(apiDescription, description); return Task.CompletedTask; }); return options; } private static string BuildDescription(ApiVersionDescription api, string description) { var text = new StringBuilder(description); if (api.IsDeprecated) { if (text.Length > 0) { if (text[^1] != '.') { text.Append('.'); } text.Append(' '); } text.Append("This API version has been deprecated."); } if (api.SunsetPolicy is { } policy) { if (policy.Date is { } when) { if (text.Length > 0) { text.Append(' '); } text.Append("The API will be sunset on ") .Append(when.Date.ToShortDateString()) .Append('.'); } if (policy.HasLinks) { text.AppendLine(); var rendered = false; foreach (var link in policy.Links.Where(l => l.Type == "text/html")) { if (!rendered) { text.Append("

    Links

    "); } } } return text.ToString(); } public static OpenApiOptions ApplySecuritySchemeDefinitions(this OpenApiOptions options) { options.AddDocumentTransformer(); return options; } public static OpenApiOptions ApplyAuthorizationChecks(this OpenApiOptions options, string[] scopes) { options.AddOperationTransformer((operation, context, cancellationToken) => { var metadata = context.Description.ActionDescriptor.EndpointMetadata; if (!metadata.OfType().Any()) { return Task.CompletedTask; } operation.Responses ??= new OpenApiResponses(); operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); var oAuthScheme = new OpenApiSecuritySchemeReference("oauth2", null); operation.Security = new List { new() { [oAuthScheme] = scopes.ToList() } }; return Task.CompletedTask; }); return options; } public static OpenApiOptions ApplyOperationDeprecatedStatus(this OpenApiOptions options) { options.AddOperationTransformer((operation, context, cancellationToken) => { var apiDescription = context.Description; operation.Deprecated |= apiDescription.IsDeprecated(); return Task.CompletedTask; }); return options; } public static OpenApiOptions ApplyApiVersionDescription(this OpenApiOptions options) { options.AddOperationTransformer((operation, context, cancellationToken) => { // Find parameter named "api-version" and add a description to it var apiVersionParameter = operation.Parameters?.FirstOrDefault(p => p.Name == "api-version"); if (apiVersionParameter is not null) { apiVersionParameter.Description = "The API version, in the format 'major.minor'."; if (apiVersionParameter.Schema is OpenApiSchema targetSchema) { switch (context.DocumentName) { case "v1": targetSchema.Example = JsonNode.Parse("\"1.0\""); break; case "v2": targetSchema.Example = JsonNode.Parse("\"2.0\""); break; } } } return Task.CompletedTask; }); return options; } private class SecuritySchemeDefinitionsTransformer(IConfiguration configuration) : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { var identitySection = configuration.GetSection("Identity"); if (!identitySection.Exists()) { return Task.CompletedTask; } var identityUrlExternal = identitySection.GetRequiredValue("Url"); var scopes = identitySection.GetRequiredSection("Scopes").GetChildren().ToDictionary(p => p.Key, p => p.Value ?? string.Empty); var securityScheme = new OpenApiSecurityScheme { Type = SecuritySchemeType.OAuth2, Flows = new OpenApiOAuthFlows() { // TODO: Change this to use Authorization Code flow with PKCE Implicit = new OpenApiOAuthFlow() { AuthorizationUrl = new Uri($"{identityUrlExternal}/connect/authorize"), TokenUrl = new Uri($"{identityUrlExternal}/connect/token"), Scopes = scopes, } } }; document.Components ??= new(); document.Components.SecuritySchemes ??= new Dictionary(); document.Components.SecuritySchemes.Add("oauth2", securityScheme); return Task.CompletedTask; } } } ================================================ FILE: src/eShop.ServiceDefaults/eShop.ServiceDefaults.csproj ================================================  net10.0 enable ================================================ FILE: tests/Basket.UnitTests/Basket.UnitTests.csproj ================================================  net10.0 false false false Exe all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/Basket.UnitTests/BasketServiceTests.cs ================================================ using System.Security.Claims; using eShop.Basket.API.Repositories; using eShop.Basket.API.Grpc; using eShop.Basket.API.Model; using eShop.Basket.UnitTests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using BasketItem = eShop.Basket.API.Model.BasketItem; namespace eShop.Basket.UnitTests; [TestClass] public class BasketServiceTests { public TestContext TestContext { get; set; } [TestMethod] public async Task GetBasketReturnsEmptyForNoUser() { var mockRepository = Substitute.For(); var service = new BasketService(mockRepository, NullLogger.Instance); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); serverCallContext.SetUserState("__HttpContext", new DefaultHttpContext()); var response = await service.GetBasket(new GetBasketRequest(), serverCallContext); Assert.IsInstanceOfType(response); Assert.IsEmpty(response.Items); } [TestMethod] public async Task GetBasketReturnsItemsForValidUserId() { var mockRepository = Substitute.For(); List items = [new BasketItem { Id = "some-id" }]; mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items })); var service = new BasketService(mockRepository, NullLogger.Instance); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); var httpContext = new DefaultHttpContext(); httpContext.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "1")])); serverCallContext.SetUserState("__HttpContext", httpContext); var response = await service.GetBasket(new GetBasketRequest(), serverCallContext); Assert.IsInstanceOfType(response); Assert.HasCount(1, response.Items); } [TestMethod] public async Task GetBasketReturnsEmptyForInvalidUserId() { var mockRepository = Substitute.For(); List items = [new BasketItem { Id = "some-id" }]; mockRepository.GetBasketAsync("1").Returns(Task.FromResult(new CustomerBasket { BuyerId = "1", Items = items })); var service = new BasketService(mockRepository, NullLogger.Instance); var serverCallContext = TestServerCallContext.Create(cancellationToken: TestContext.CancellationToken); var httpContext = new DefaultHttpContext(); serverCallContext.SetUserState("__HttpContext", httpContext); var response = await service.GetBasket(new GetBasketRequest(), serverCallContext); Assert.IsInstanceOfType(response); Assert.IsEmpty(response.Items); } } ================================================ FILE: tests/Basket.UnitTests/GlobalUsings.cs ================================================ global using System; global using System.Collections.Generic; global using System.Threading; global using System.Threading.Tasks; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Mvc; global using NSubstitute; global using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] ================================================ FILE: tests/Basket.UnitTests/Helpers/TestServerCallContext.cs ================================================ using Grpc.Core; namespace eShop.Basket.UnitTests.Helpers; public class TestServerCallContext : ServerCallContext { private readonly Metadata _requestHeaders; private readonly CancellationToken _cancellationToken; private readonly Metadata _responseTrailers; private readonly AuthContext _authContext; private readonly Dictionary _userState; private WriteOptions _writeOptions; public Metadata ResponseHeaders { get; private set; } private TestServerCallContext(Metadata requestHeaders, CancellationToken cancellationToken) { _requestHeaders = requestHeaders; _cancellationToken = cancellationToken; _responseTrailers = new Metadata(); _authContext = new AuthContext(string.Empty, new Dictionary>()); _userState = new Dictionary(); } protected override string MethodCore => "MethodName"; protected override string HostCore => "HostName"; protected override string PeerCore => "PeerName"; protected override DateTime DeadlineCore { get; } protected override Metadata RequestHeadersCore => _requestHeaders; protected override CancellationToken CancellationTokenCore => _cancellationToken; protected override Metadata ResponseTrailersCore => _responseTrailers; protected override Status StatusCore { get; set; } protected override WriteOptions WriteOptionsCore { get => _writeOptions; set { _writeOptions = value; } } protected override AuthContext AuthContextCore => _authContext; protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions options) { throw new NotImplementedException(); } protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) { if (ResponseHeaders != null) { throw new InvalidOperationException("Response headers have already been written."); } ResponseHeaders = responseHeaders; return Task.CompletedTask; } protected override IDictionary UserStateCore => _userState; internal void SetUserState(object key, object value) => _userState[key] = value; public static TestServerCallContext Create(Metadata requestHeaders = null, CancellationToken cancellationToken = default) { return new TestServerCallContext(requestHeaders: new Metadata(), cancellationToken); } } ================================================ FILE: tests/Catalog.FunctionalTests/Catalog.FunctionalTests.csproj ================================================  net10.0 false false Exe ================================================ FILE: tests/Catalog.FunctionalTests/CatalogApiFixture.cs ================================================ using System.Reflection; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.AspNetCore.Mvc.Testing; namespace eShop.Catalog.FunctionalTests; public sealed class CatalogApiFixture : WebApplicationFactory, IAsyncLifetime { private readonly IHost _app; public IResourceBuilder Postgres { get; private set; } private string _postgresConnectionString; public CatalogApiFixture() { var options = new DistributedApplicationOptions { AssemblyName = typeof(CatalogApiFixture).Assembly.FullName, DisableDashboard = true }; var appBuilder = DistributedApplication.CreateBuilder(options); Postgres = appBuilder.AddPostgres("CatalogDB") .WithImage("ankane/pgvector") .WithImageTag("latest"); _app = appBuilder.Build(); } protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureHostConfiguration(config => { config.AddInMemoryCollection(new Dictionary { { $"ConnectionStrings:{Postgres.Resource.Name}", _postgresConnectionString }, }); }); return base.CreateHost(builder); } public new async Task DisposeAsync() { await base.DisposeAsync(); await _app.StopAsync(); if (_app is IAsyncDisposable asyncDisposable) { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else { _app.Dispose(); } } public async ValueTask InitializeAsync() { await _app.StartAsync(); _postgresConnectionString = await Postgres.Resource.GetConnectionStringAsync(); } } ================================================ FILE: tests/Catalog.FunctionalTests/CatalogApiTests.cs ================================================ using System.Net.Http.Json; using System.Text.Json; using Asp.Versioning; using Asp.Versioning.Http; using eShop.Catalog.API.Model; using Microsoft.AspNetCore.Mvc.Testing; namespace eShop.Catalog.FunctionalTests; public sealed class CatalogApiTests : IClassFixture { private readonly WebApplicationFactory _webApplicationFactory; private readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); public CatalogApiTests(CatalogApiFixture fixture) { _webApplicationFactory = fixture; } private HttpClient CreateHttpClient(ApiVersion apiVersion) { var handler = new ApiVersionHandler(new QueryStringApiVersionWriter(), apiVersion); return _webApplicationFactory.CreateDefaultClient(handler); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemsRespectsPageSize(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("/api/catalog/items?pageIndex=0&pageSize=5", TestContext.Current.CancellationToken); // Assert response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert 103 total items (101 seeded + 2 added by AddCatalogItem tests) with 5 retrieved from index 0 Assert.Equal(103, result.Count); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task UpdateCatalogItemWorksWithoutPriceUpdate(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act - 1 var response = await _httpClient.GetAsync("/api/catalog/items/1", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var itemToUpdate = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Act - 2 var priorAvailableStock = itemToUpdate.AvailableStock; itemToUpdate.AvailableStock -= 1; response = version switch { 1.0 => await _httpClient.PutAsJsonAsync("/api/catalog/items", itemToUpdate, TestContext.Current.CancellationToken), 2.0 => await _httpClient.PutAsJsonAsync($"/api/catalog/items/{itemToUpdate.Id}", itemToUpdate, TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; response.EnsureSuccessStatusCode(); // Act - 3 response = await _httpClient.GetAsync("/api/catalog/items/1", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var updatedItem = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Assert - 1 Assert.Equal(itemToUpdate.Id, updatedItem.Id); Assert.NotEqual(priorAvailableStock, updatedItem.AvailableStock); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task UpdateCatalogItemWorksWithPriceUpdate(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act - 1 var response = await _httpClient.GetAsync("/api/catalog/items/1", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var itemToUpdate = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Act - 2 var priorAvailableStock = itemToUpdate.AvailableStock; itemToUpdate.AvailableStock -= 1; itemToUpdate.Price = 1.99m; response = version switch { 1.0 => await _httpClient.PutAsJsonAsync("/api/catalog/items", itemToUpdate, TestContext.Current.CancellationToken), 2.0 => await _httpClient.PutAsJsonAsync($"/api/catalog/items/{itemToUpdate.Id}", itemToUpdate, TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; response.EnsureSuccessStatusCode(); // Act - 3 response = await _httpClient.GetAsync("/api/catalog/items/1", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var updatedItem = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Assert - 1 Assert.Equal(itemToUpdate.Id, updatedItem.Id); Assert.Equal(1.99m, updatedItem.Price); Assert.NotEqual(priorAvailableStock, updatedItem.AvailableStock); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemsbyIds(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("/api/catalog/items/by?ids=1&ids=2&ids=3", TestContext.Current.CancellationToken); // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert 3 items Assert.Equal(3, result.Count); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemWithId(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("/api/catalog/items/2", TestContext.Current.CancellationToken); // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Assert Assert.Equal(2, result.Id); Assert.NotNull(result); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemWithExactName(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = version switch { 1.0 => await _httpClient.GetAsync("api/catalog/items/by/Wanderer%20Black%20Hiking%20Boots?PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), 2.0 => await _httpClient.GetAsync("api/catalog/items?name=Wanderer%20Black%20Hiking%20Boots&PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.NotNull(result.Data); Assert.Equal(1, result.Count); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); Assert.Equal("Wanderer Black Hiking Boots", result.Data.ToList().FirstOrDefault().Name); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemWithPartialName(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = version switch { 1.0 => await _httpClient.GetAsync("api/catalog/items/by/Alpine?PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), 2.0 => await _httpClient.GetAsync("api/catalog/items?name=Alpine&PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.NotNull(result.Data); Assert.Equal(4, result.Count); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); Assert.Contains("Alpine", result.Data.ToList().FirstOrDefault().Name); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemPicWithId(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("api/catalog/items/1/pic", TestContext.Current.CancellationToken); // Arrange response.EnsureSuccessStatusCode(); var result = response.Content.Headers.ContentType.MediaType; // Assert Assert.Equal("image/webp", result); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemWithsemanticrelevance(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = version switch { 1.0 => await _httpClient.GetAsync("api/catalog/items/withsemanticrelevance/Wanderer?PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), 2.0 => await _httpClient.GetAsync("api/catalog/items/withsemanticrelevance?text=Wanderer&PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.Equal(1, result.Count); Assert.NotNull(result.Data); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetCatalogItemWithTypeIdBrandId(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = version switch { 1.0 => await _httpClient.GetAsync("api/catalog/items/type/3/brand/3?PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), 2.0 => await _httpClient.GetAsync("api/catalog/items?type=3&brand=3&PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.NotNull(result.Data); Assert.Equal(4, result.Count); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); Assert.Equal(3, result.Data.ToList().FirstOrDefault().CatalogTypeId); Assert.Equal(3, result.Data.ToList().FirstOrDefault().CatalogBrandId); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetAllCatalogTypeItemWithBrandId(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = version switch { 1.0 => await _httpClient.GetAsync("api/catalog/items/type/all/brand/3?PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), 2.0 => await _httpClient.GetAsync("api/catalog/items?brand=3&PageSize=5&PageIndex=0", TestContext.Current.CancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(version), version, null) }; // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.NotNull(result.Data); Assert.Equal(11, result.Count); Assert.Equal(0, result.PageIndex); Assert.Equal(5, result.PageSize); Assert.Equal(3, result.Data.ToList().FirstOrDefault().CatalogBrandId); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetAllCatalogTypes(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("api/catalog/catalogtypes", TestContext.Current.CancellationToken); // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.Equal(8, result.Count); Assert.NotNull(result); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task GetAllCatalogBrands(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); // Act var response = await _httpClient.GetAsync("api/catalog/catalogbrands", TestContext.Current.CancellationToken); // Arrange response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); // Assert Assert.Equal(13, result.Count); Assert.NotNull(result); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task AddCatalogItem(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); var id = version switch { 1.0 => 10015, 2.0 => 10016, _ => 0 }; // Act - 1 var bodyContent = new CatalogItem("TestCatalog1") { Id = id, Description = "Test catalog description 1", Price = 11000.08m, PictureFileName = null, CatalogTypeId = 8, CatalogType = null, CatalogBrandId = 13, CatalogBrand = null, AvailableStock = 100, RestockThreshold = 10, MaxStockThreshold = 200, OnReorder = false }; var response = await _httpClient.PostAsJsonAsync("/api/catalog/items", bodyContent, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); // Act - 2 response = await _httpClient.GetAsync($"/api/catalog/items/{id}", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var addedItem = JsonSerializer.Deserialize(body, _jsonSerializerOptions); // Assert - 1 Assert.Equal(bodyContent.Id, addedItem.Id); } [Theory] [InlineData(1.0)] [InlineData(2.0)] public async Task DeleteCatalogItem(double version) { var _httpClient = CreateHttpClient(new ApiVersion(version)); var id = version switch { 1.0 => 5, 2.0 => 6, _ => 0 }; //Act - 1 var response = await _httpClient.DeleteAsync($"/api/catalog/items/{id}", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); // Act - 2 var response1 = await _httpClient.GetAsync($"/api/catalog/items/{id}", TestContext.Current.CancellationToken); var responseStatus = response1.StatusCode; // Assert - 1 Assert.Equal("NoContent", response.StatusCode.ToString()); Assert.Equal("NotFound", responseStatus.ToString()); } } ================================================ FILE: tests/Catalog.FunctionalTests/GlobalUsings.cs ================================================ global using System; global using System.Collections.Generic; global using System.Linq; global using System.Net.Http; global using System.Threading.Tasks; global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.TestHost; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.Hosting; global using Xunit; ================================================ FILE: tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj ================================================ net10.0 enable enable false true Exe false ================================================ FILE: tests/ClientApp.UnitTests/ClientApp.UnitTests.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClientApp.UnitTests", "ClientApp.UnitTests.csproj", "{54B18315-E610-49D7-A10E-357C7DC20D71}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {54B18315-E610-49D7-A10E-357C7DC20D71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54B18315-E610-49D7-A10E-357C7DC20D71}.Debug|Any CPU.Build.0 = Debug|Any CPU {54B18315-E610-49D7-A10E-357C7DC20D71}.Release|Any CPU.ActiveCfg = Release|Any CPU {54B18315-E610-49D7-A10E-357C7DC20D71}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F305FAE3-88DD-49C5-A802-7897D8EA350F} EndGlobalSection EndGlobal ================================================ FILE: tests/ClientApp.UnitTests/GlobalUsings.cs ================================================ global using eShop.ClientApp.Services; global using eShop.ClientApp.Services.AppEnvironment; global using eShop.ClientApp.Services.Basket; global using eShop.ClientApp.Services.Catalog; global using eShop.ClientApp.Services.Order; global using eShop.ClientApp.Services.Settings; global using eShop.ClientApp.ViewModels; global using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] ================================================ FILE: tests/ClientApp.UnitTests/Mocks/MockDialogService.cs ================================================ namespace ClientApp.UnitTests.Mocks; public class MockDialogService : IDialogService { public Task ShowAlertAsync(string message, string title, string buttonLabel) { return Task.CompletedTask; } } ================================================ FILE: tests/ClientApp.UnitTests/Mocks/MockNavigationService.cs ================================================ namespace ClientApp.UnitTests.Mocks; public class MockNavigationService : INavigationService { public Task InitializeAsync() { return Task.CompletedTask; } public Task NavigateToAsync(string route, IDictionary? routeParameters = null) { return Task.CompletedTask; } public Task PopAsync() { return Task.CompletedTask; } } ================================================ FILE: tests/ClientApp.UnitTests/Mocks/MockSettingsService.cs ================================================ using eShop.ClientApp.Models.Token; namespace ClientApp.UnitTests.Mocks; public class MockSettingsService : ISettingsService { private const string AccessToken = "access_token"; private const string IdToken = "id_token"; private const string IdUseMocks = "use_mocks"; private const string IdIdentityBase = "url_base"; private const string IdGatewayMarketingBase = "url_marketing"; private const string IdGatewayShoppingBase = "url_shopping"; private const string IdUseFakeLocation = "use_fake_location"; private const string IdLatitude = "latitude"; private const string IdLongitude = "longitude"; private const string IdAllowGpsLocation = "allow_gps_location"; private const string AccessTokenDefault = "default_access_token"; private const string IdTokenDefault = ""; private const bool UseMocksDefault = true; private const bool UseFakeLocationDefault = false; private const bool AllowGpsLocationDefault = false; private const double FakeLatitudeDefault = 47.604610d; private const double FakeLongitudeDefault = -122.315752d; private const string UrlIdentityDefault = "https://13.88.8.119"; private const string UrlGatewayMarketingDefault = "https://13.88.8.119"; private const string UrlGatewayShoppingDefault = "https://13.88.8.119"; private readonly IDictionary _settings = new Dictionary(); private UserToken? _userToken; public string AuthAccessToken { get => GetValueOrDefault(AccessToken, AccessTokenDefault); set => AddOrUpdateValue(AccessToken, value); } public string AuthIdToken { get => GetValueOrDefault(IdToken, IdTokenDefault); set => AddOrUpdateValue(IdToken, value); } public Task GetUserTokenAsync() { return Task.FromResult(_userToken); } public Task SetUserTokenAsync(UserToken? userToken) { _userToken = userToken; return Task.CompletedTask; } public bool UseMocks { get => GetValueOrDefault(IdUseMocks, UseMocksDefault); set => AddOrUpdateValue(IdUseMocks, value); } public string DefaultEndpoint { get => GetValueOrDefault(nameof(DefaultEndpoint), string.Empty); set => AddOrUpdateValue(nameof(DefaultEndpoint), value); } public string RegistrationEndpoint { get => GetValueOrDefault(nameof(RegistrationEndpoint), string.Empty); set => AddOrUpdateValue(nameof(RegistrationEndpoint), value); } public string AuthorizeEndpoint { get => GetValueOrDefault(nameof(AuthorizeEndpoint), string.Empty); set => AddOrUpdateValue(nameof(AuthorizeEndpoint), value); } public string UserInfoEndpoint { get => GetValueOrDefault(nameof(UserInfoEndpoint), string.Empty); set => AddOrUpdateValue(nameof(UserInfoEndpoint), value); } public string ClientId { get => GetValueOrDefault(nameof(ClientId), string.Empty); set => AddOrUpdateValue(nameof(ClientId), value); } public string ClientSecret { get => GetValueOrDefault(nameof(ClientSecret), string.Empty); set => AddOrUpdateValue(nameof(ClientSecret), value); } public string CallbackUri { get => GetValueOrDefault(nameof(CallbackUri), string.Empty); set => AddOrUpdateValue(nameof(CallbackUri), value); } public string IdentityEndpointBase { get => GetValueOrDefault(IdIdentityBase, UrlIdentityDefault); set => AddOrUpdateValue(IdIdentityBase, value); } public string GatewayCatalogEndpointBase { get => GetValueOrDefault(nameof(GatewayCatalogEndpointBase), string.Empty); set => AddOrUpdateValue(nameof(GatewayCatalogEndpointBase), value); } public string GatewayOrdersEndpointBase { get => GetValueOrDefault(nameof(GatewayOrdersEndpointBase), string.Empty); set => AddOrUpdateValue(nameof(GatewayOrdersEndpointBase), value); } public string GatewayBasketEndpointBase { get => GetValueOrDefault(nameof(GatewayBasketEndpointBase), string.Empty); set => AddOrUpdateValue(nameof(GatewayBasketEndpointBase), value); } public string GatewayShoppingEndpointBase { get => GetValueOrDefault(IdGatewayShoppingBase, UrlGatewayShoppingDefault); set => AddOrUpdateValue(IdGatewayShoppingBase, value); } public string GatewayMarketingEndpointBase { get => GetValueOrDefault(IdGatewayMarketingBase, UrlGatewayMarketingDefault); set => AddOrUpdateValue(IdGatewayMarketingBase, value); } public bool UseFakeLocation { get => GetValueOrDefault(IdUseFakeLocation, UseFakeLocationDefault); set => AddOrUpdateValue(IdUseFakeLocation, value); } public string Latitude { get => GetValueOrDefault(IdLatitude, FakeLatitudeDefault.ToString()); set => AddOrUpdateValue(IdLatitude, value); } public string Longitude { get => GetValueOrDefault(IdLongitude, FakeLongitudeDefault.ToString()); set => AddOrUpdateValue(IdLongitude, value); } public bool AllowGpsLocation { get => GetValueOrDefault(IdAllowGpsLocation, AllowGpsLocationDefault); set => AddOrUpdateValue(IdAllowGpsLocation, value); } public void AddOrUpdateValue(string key, bool value) => AddOrUpdateValueInternal(key, value); public void AddOrUpdateValue(string key, string value) => AddOrUpdateValueInternal(key, value); public bool GetValueOrDefault(string key, bool defaultValue) => GetValueOrDefaultInternal(key, defaultValue); public string GetValueOrDefault(string key, string defaultValue) => GetValueOrDefaultInternal(key, defaultValue); void AddOrUpdateValueInternal(string key, T value) { if (value is null) { Remove(key); } else { _settings[key] = value; } } T GetValueOrDefaultInternal(string key, T defaultValue = default!) => _settings.TryGetValue(key, out object? value) ? null != value ? (T)value : defaultValue : defaultValue; void Remove(string key) { if (_settings.ContainsKey(key)) { _settings.Remove(key); } } } ================================================ FILE: tests/ClientApp.UnitTests/Mocks/MockViewModel.cs ================================================ using eShop.ClientApp.Validations; using eShop.ClientApp.ViewModels.Base; namespace ClientApp.UnitTests.Mocks; public class MockViewModel : ViewModelBase { public ValidatableObject Forename { get; } = new(); public ValidatableObject Surname { get; } = new(); public MockViewModel(INavigationService navigationService) : base(navigationService) { Forename = new ValidatableObject(); Surname = new ValidatableObject(); Forename.Validations.Add(new IsNotNullOrEmptyRule { ValidationMessage = "Forename is required." }); Surname.Validations.Add(new IsNotNullOrEmptyRule { ValidationMessage = "Surname name is required." }); } public bool Validate() { bool isValidForename = Forename.Validate(); bool isValidSurname = Surname.Validate(); return isValidForename && isValidSurname; } } ================================================ FILE: tests/ClientApp.UnitTests/Services/BasketServiceTests.cs ================================================ namespace ClientApp.UnitTests.Services; [TestClass] public class BasketServiceTests { [TestMethod] public async Task GetFakeBasketTest() { var catalogMockService = new CatalogMockService(); var result = await catalogMockService.GetCatalogAsync(); Assert.AreNotEqual(0, result.Count()); } } ================================================ FILE: tests/ClientApp.UnitTests/Services/CatalogServiceTests.cs ================================================ namespace ClientApp.UnitTests.Services; [TestClass] public class CatalogServiceTests { [TestMethod] public async Task GetFakeCatalogTest() { var catalogMockService = new CatalogMockService(); var catalog = await catalogMockService.GetCatalogAsync(); Assert.AreNotEqual(0, catalog.Count()); } [TestMethod] public async Task GetFakeCatalogBrandTest() { var catalogMockService = new CatalogMockService(); var catalogBrand = await catalogMockService.GetCatalogBrandAsync(); Assert.AreNotEqual(0, catalogBrand.Count()); } [TestMethod] public async Task GetFakeCatalogTypeTest() { var catalogMockService = new CatalogMockService(); var catalogType = await catalogMockService.GetCatalogTypeAsync(); Assert.AreNotEqual(0, catalogType.Count()); } } ================================================ FILE: tests/ClientApp.UnitTests/Services/OrdersServiceTests.cs ================================================ using ClientApp.UnitTests.Mocks; namespace ClientApp.UnitTests.Services; [TestClass] public class OrdersServiceTests { private readonly ISettingsService _settingsService; public OrdersServiceTests() { _settingsService = new MockSettingsService(); } [TestMethod] public async Task GetFakeOrderTest() { var ordersMockService = new OrderMockService(); var order = await ordersMockService.GetOrderAsync(1); Assert.IsNotNull(order); } [TestMethod] public async Task GetFakeOrdersTest() { var ordersMockService = new OrderMockService(); var result = await ordersMockService.GetOrdersAsync(); Assert.AreNotEqual(0, result.Count()); } } ================================================ FILE: tests/ClientApp.UnitTests/TestingExtensions.cs ================================================ using System.Windows.Input; using CommunityToolkit.Mvvm.Input; namespace ClientApp.UnitTests; public static class TestingExtensions { public static async Task ExecuteUntilComplete(this ICommand command, object? parameter = null) { if (command is IAsyncRelayCommand arc) { await arc.ExecuteAsync(parameter); return; } command.Execute(parameter); } } ================================================ FILE: tests/ClientApp.UnitTests/ViewModels/CatalogItemViewModelTests.cs ================================================ using ClientApp.UnitTests.Mocks; using CommunityToolkit.Mvvm.Messaging; using eShop.ClientApp.Messages; using eShop.ClientApp.Models.Catalog; using eShop.ClientApp.Services.Identity; namespace ClientApp.UnitTests.ViewModels; [TestClass] public class CatalogItemViewModelTests { private readonly INavigationService _navigationService; private readonly IAppEnvironmentService _appEnvironmentService; public CatalogItemViewModelTests() { _navigationService = new MockNavigationService(); var mockCatalogService = new CatalogMockService(); var mockOrderService = new OrderMockService(); var mockIdentityService = new IdentityMockService(); var mockBasketService = new BasketMockService(); _appEnvironmentService = new AppEnvironmentService( mockBasketService, mockBasketService, mockCatalogService, mockCatalogService, mockOrderService, mockOrderService, mockIdentityService, mockIdentityService); _appEnvironmentService.UpdateDependencies(true); } [TestMethod] public void AddCatalogItemCommandIsNotNullTest() { var CatalogItemViewModel = new CatalogItemViewModel(_appEnvironmentService, _navigationService); Assert.IsNotNull(CatalogItemViewModel.AddCatalogItemCommand); } [TestMethod] public async Task AddCatalogItemCommandSendsAddProductMessageTest() { bool messageReceived = false; var catalogItemViewModel = new CatalogItemViewModel(_appEnvironmentService, _navigationService); catalogItemViewModel.CatalogItem = new CatalogItem {Id = 123, Name = "test", Price = 1.23m,}; WeakReferenceMessenger.Default .Register( this, (_, message) => { messageReceived = true; }); await catalogItemViewModel.AddCatalogItemCommand.ExecuteUntilComplete(); Assert.IsTrue(messageReceived); } } ================================================ FILE: tests/ClientApp.UnitTests/ViewModels/CatalogViewModelTests.cs ================================================ using ClientApp.UnitTests.Mocks; using eShop.ClientApp.Services.Identity; namespace ClientApp.UnitTests.ViewModels; [TestClass] public class CatalogViewModelTests { private readonly INavigationService _navigationService; private readonly IAppEnvironmentService _appEnvironmentService; public CatalogViewModelTests() { _navigationService = new MockNavigationService(); var mockBasketService = new BasketMockService(); var mockCatalogService = new CatalogMockService(); var mockOrderService = new OrderMockService(); var mockIdentityService = new IdentityMockService(); _appEnvironmentService = new AppEnvironmentService( mockBasketService, mockBasketService, mockCatalogService, mockCatalogService, mockOrderService, mockOrderService, mockIdentityService, mockIdentityService); _appEnvironmentService.UpdateDependencies(true); } [TestMethod] public void FilterCommandIsNotNullTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsNotNull(catalogViewModel.FilterCommand); } [TestMethod] public void ClearFilterCommandIsNotNullTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsNotNull(catalogViewModel.ClearFilterCommand); } [TestMethod] public void ProductsPropertyIsEmptyWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsEmpty(catalogViewModel.Products); } [TestMethod] public void BrandsPropertyIsEmptyWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsEmpty(catalogViewModel.Brands); } [TestMethod] public void BrandPropertyIsNullWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsNull(catalogViewModel.SelectedBrand); } [TestMethod] public void TypesPropertyIsEmptyWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsEmpty(catalogViewModel.Types); } [TestMethod] public void TypePropertyIsNullWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsNull(catalogViewModel.SelectedType); } [TestMethod] public void IsFilterPropertyIsFalseWhenViewModelInstantiatedTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); Assert.IsFalse(catalogViewModel.IsFiltering); } [TestMethod] public async Task ProductsPropertyIsNotNullAfterViewModelInitializationTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); await catalogViewModel.InitializeAsync(); Assert.IsNotNull(catalogViewModel.Products); } [TestMethod] public async Task BrandsPropertyIsNotNullAfterViewModelInitializationTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); await catalogViewModel.InitializeAsync(); Assert.IsNotNull(catalogViewModel.Brands); } [TestMethod] public async Task TypesPropertyIsNotNullAfterViewModelInitializationTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); await catalogViewModel.InitializeAsync(); Assert.IsNotNull(catalogViewModel.Types); } [TestMethod] public async Task SettingBadgeCountPropertyShouldRaisePropertyChanged() { bool invoked = false; var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); catalogViewModel.PropertyChanged += (_, e) => { if (e?.PropertyName?.Equals(nameof(CatalogViewModel.BadgeCount)) ?? false) { invoked = true; } }; await catalogViewModel.InitializeAsync(); Assert.IsTrue(invoked); } [TestMethod] public async Task ClearFilterCommandResetsPropertiesTest() { var catalogViewModel = new CatalogViewModel(_appEnvironmentService, _navigationService); await catalogViewModel.InitializeAsync(); await catalogViewModel.ClearFilterCommand.ExecuteUntilComplete(null); Assert.IsNull(catalogViewModel.SelectedBrand); Assert.IsNull(catalogViewModel.SelectedType); Assert.IsNotNull(catalogViewModel.Products); } } ================================================ FILE: tests/ClientApp.UnitTests/ViewModels/MainViewModelTests.cs ================================================ using ClientApp.UnitTests.Mocks; namespace ClientApp.UnitTests.ViewModels; [TestClass] public class MainViewModelTests { private readonly INavigationService _navigationService; public MainViewModelTests() { _navigationService = new MockNavigationService(); } [TestMethod] public void SettingsCommandIsNotNullWhenViewModelInstantiatedTest() { var mainViewModel = new MainViewModel(_navigationService); Assert.IsNotNull(mainViewModel.SettingsCommand); } [TestMethod] public void IsBusyPropertyIsFalseWhenViewModelInstantiatedTest() { var mainViewModel = new MainViewModel(_navigationService); Assert.IsFalse(mainViewModel.IsBusy); } } ================================================ FILE: tests/ClientApp.UnitTests/ViewModels/MockViewModelTests.cs ================================================ using ClientApp.UnitTests.Mocks; namespace ClientApp.UnitTests.ViewModels; [TestClass] public class MockViewModelTests { private readonly INavigationService _navigationService; public MockViewModelTests() { _navigationService = new MockNavigationService(); } [TestMethod] public void CheckValidationFailsWhenPropertiesAreEmptyTest() { var mockViewModel = new MockViewModel(_navigationService); bool isValid = mockViewModel.Validate(); Assert.IsFalse(isValid); Assert.IsNull(mockViewModel.Forename.Value); Assert.IsNull(mockViewModel.Surname.Value); Assert.IsFalse(mockViewModel.Forename.IsValid); Assert.IsFalse(mockViewModel.Surname.IsValid); Assert.AreNotEqual(0, mockViewModel.Forename.Errors.Count()); Assert.AreNotEqual(0, mockViewModel.Surname.Errors.Count()); } [TestMethod] public void CheckValidationFailsWhenOnlyForenameHasDataTest() { var mockViewModel = new MockViewModel(_navigationService); mockViewModel.Forename.Value = "John"; bool isValid = mockViewModel.Validate(); Assert.IsFalse(isValid); Assert.IsNotNull(mockViewModel.Forename.Value); Assert.IsNull(mockViewModel.Surname.Value); Assert.IsTrue(mockViewModel.Forename.IsValid); Assert.IsFalse(mockViewModel.Surname.IsValid); Assert.AreEqual(0, mockViewModel.Forename.Errors.Count()); Assert.AreNotEqual(0, mockViewModel.Surname.Errors.Count()); } [TestMethod] public void CheckValidationPassesWhenOnlySurnameHasDataTest() { var mockViewModel = new MockViewModel(_navigationService); mockViewModel.Surname.Value = "Smith"; bool isValid = mockViewModel.Validate(); Assert.IsFalse(isValid); Assert.IsNull(mockViewModel.Forename.Value); Assert.IsNotNull(mockViewModel.Surname.Value); Assert.IsFalse(mockViewModel.Forename.IsValid); Assert.IsTrue(mockViewModel.Surname.IsValid); Assert.AreNotEqual(0, mockViewModel.Forename.Errors.Count()); Assert.AreEqual(0, mockViewModel.Surname.Errors.Count()); } [TestMethod] public void CheckValidationPassesWhenBothPropertiesHaveDataTest() { var mockViewModel = new MockViewModel(_navigationService); mockViewModel.Forename.Value = "John"; mockViewModel.Surname.Value = "Smith"; bool isValid = mockViewModel.Validate(); Assert.IsTrue(isValid); Assert.IsNotNull(mockViewModel.Forename.Value); Assert.IsNotNull(mockViewModel.Surname.Value); Assert.IsTrue(mockViewModel.Forename.IsValid); Assert.IsTrue(mockViewModel.Surname.IsValid); Assert.AreEqual(0, mockViewModel.Forename.Errors.Count()); Assert.AreEqual(0, mockViewModel.Surname.Errors.Count()); } [TestMethod] public void SettingForenamePropertyShouldRaisePropertyChanged() { bool invoked = false; var mockViewModel = new MockViewModel(_navigationService); mockViewModel.Forename.PropertyChanged += (_, e) => { if (e?.PropertyName?.Equals(nameof(mockViewModel.Forename.Value)) ?? false) { invoked = true; } }; mockViewModel.Forename.Value = "John"; Assert.IsTrue(invoked); } [TestMethod] public void SettingSurnamePropertyShouldRaisePropertyChanged() { bool invoked = false; var mockViewModel = new MockViewModel(_navigationService); mockViewModel.Surname.PropertyChanged += (_, e) => { if (e?.PropertyName?.Equals(nameof(mockViewModel.Surname.Value)) ?? false) { invoked = true; } }; mockViewModel.Surname.Value = "Smith"; Assert.IsTrue(invoked); } } ================================================ FILE: tests/ClientApp.UnitTests/ViewModels/OrderViewModelTests.cs ================================================ using ClientApp.UnitTests.Mocks; using eShop.ClientApp.Services.Identity; namespace ClientApp.UnitTests.ViewModels; [TestClass] public class OrderViewModelTests { private readonly INavigationService _navigationService; private readonly ISettingsService _settingsService; private readonly IAppEnvironmentService _appEnvironmentService; public OrderViewModelTests() { _navigationService = new MockNavigationService(); _settingsService = new MockSettingsService(); var mockBasketService = new BasketMockService(); var mockCatalogService = new CatalogMockService(); var mockOrderService = new OrderMockService(); var mockIdentityService = new IdentityMockService(); _appEnvironmentService = new AppEnvironmentService( mockBasketService, mockBasketService, mockCatalogService, mockCatalogService, mockOrderService, mockOrderService, mockIdentityService, mockIdentityService); _appEnvironmentService.UpdateDependencies(true); } [TestMethod] public void OrderPropertyIsNullWhenViewModelInstantiatedTest() { var orderViewModel = new OrderDetailViewModel(_appEnvironmentService, _navigationService, _settingsService); Assert.IsNull(orderViewModel.Order); } [TestMethod] public async Task OrderPropertyIsNotNullAfterViewModelInitializationTest() { var orderViewModel = new OrderDetailViewModel(_appEnvironmentService, _navigationService, _settingsService); var order = await _appEnvironmentService.OrderService.GetOrderAsync(1); orderViewModel.OrderNumber = order.OrderNumber; await orderViewModel.InitializeAsync(); Assert.IsNotNull(orderViewModel.Order); } [TestMethod] public async Task SettingOrderPropertyShouldRaisePropertyChanged() { bool invoked = false; var orderViewModel = new OrderDetailViewModel(_appEnvironmentService, _navigationService, _settingsService); orderViewModel.PropertyChanged += (_, e) => { if (e?.PropertyName?.Equals(nameof(OrderDetailViewModel.Order)) ?? false) { invoked = true; } }; var order = await _appEnvironmentService.OrderService.GetOrderAsync(1); orderViewModel.OrderNumber = order.OrderNumber; await orderViewModel.InitializeAsync(); Assert.IsTrue(invoked); } } ================================================ FILE: tests/Directory.Build.props ================================================ Recommended true ================================================ FILE: tests/Ordering.FunctionalTests/AutoAuthorizeMiddleware.cs ================================================ namespace eShop.Ordering.FunctionalTests; class AutoAuthorizeMiddleware { public const string IDENTITY_ID = "9e3163b9-1ae6-4652-9dc6-7898ab7b7a00"; private readonly RequestDelegate _next; public AutoAuthorizeMiddleware(RequestDelegate rd) { _next = rd; } public async Task Invoke(HttpContext httpContext) { var identity = new ClaimsIdentity("cookies"); identity.AddClaim(new Claim("sub", IDENTITY_ID)); identity.AddClaim(new Claim("unique_name", IDENTITY_ID)); identity.AddClaim(new Claim(ClaimTypes.Name, IDENTITY_ID)); httpContext.User.AddIdentity(identity); await _next.Invoke(httpContext); } } ================================================ FILE: tests/Ordering.FunctionalTests/GlobalUsings.cs ================================================ global using System; global using System.Collections.Generic; global using System.Linq; global using System.Net.Http; global using System.Security.Claims; global using System.Threading.Tasks; global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.TestHost; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Xunit; ================================================ FILE: tests/Ordering.FunctionalTests/Ordering.FunctionalTests.csproj ================================================  net10.0 false false Exe ================================================ FILE: tests/Ordering.FunctionalTests/OrderingApiFixture.cs ================================================ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Hosting; namespace eShop.Ordering.FunctionalTests; public sealed class OrderingApiFixture : WebApplicationFactory, IAsyncLifetime { private readonly IHost _app; public IResourceBuilder Postgres { get; private set; } public IResourceBuilder IdentityDB { get; private set; } public IResourceBuilder IdentityApi { get; private set; } private string _postgresConnectionString; public OrderingApiFixture() { var options = new DistributedApplicationOptions { AssemblyName = typeof(OrderingApiFixture).Assembly.FullName, DisableDashboard = true }; var appBuilder = DistributedApplication.CreateBuilder(options); Postgres = appBuilder.AddPostgres("OrderingDB"); IdentityDB = appBuilder.AddPostgres("IdentityDB"); IdentityApi = appBuilder.AddProject("identity-api").WithReference(IdentityDB); _app = appBuilder.Build(); } protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureHostConfiguration(config => { config.AddInMemoryCollection(new Dictionary { { $"ConnectionStrings:{Postgres.Resource.Name}", _postgresConnectionString }, { "Identity:Url", IdentityApi.GetEndpoint("http").Url } }); }); builder.ConfigureServices(services => { services.AddSingleton(new AutoAuthorizeStartupFilter()); }); return base.CreateHost(builder); } public new async Task DisposeAsync() { await base.DisposeAsync(); await _app.StopAsync(); if (_app is IAsyncDisposable asyncDisposable) { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else { _app.Dispose(); } } public async ValueTask InitializeAsync() { await _app.StartAsync(); _postgresConnectionString = await Postgres.Resource.GetConnectionStringAsync(); } private class AutoAuthorizeStartupFilter : IStartupFilter { public Action Configure(Action next) { return builder => { builder.UseMiddleware(); next(builder); }; } } } ================================================ FILE: tests/Ordering.FunctionalTests/OrderingApiTests.cs ================================================ using System.Net; using System.Text; using System.Text.Json; using Asp.Versioning; using Asp.Versioning.Http; using eShop.Ordering.API.Application.Commands; using eShop.Ordering.API.Application.Models; using eShop.Ordering.API.Application.Queries; using Microsoft.AspNetCore.Mvc.Testing; namespace eShop.Ordering.FunctionalTests; public sealed class OrderingApiTests : IClassFixture { private readonly WebApplicationFactory _webApplicationFactory; private readonly HttpClient _httpClient; public OrderingApiTests(OrderingApiFixture fixture) { var handler = new ApiVersionHandler(new QueryStringApiVersionWriter(), new ApiVersion(1.0)); _webApplicationFactory = fixture; _httpClient = _webApplicationFactory.CreateDefaultClient(handler); } [Fact] public async Task GetAllStoredOrdersWorks() { // Act var response = await _httpClient.GetAsync("api/orders", TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task CancelWithEmptyGuidFails() { // Act var content = new StringContent(BuildOrder(), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.Empty.ToString() } } }; var response = await _httpClient.PutAsync("/api/orders/cancel", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task CancelNonExistentOrderFails() { // Act var content = new StringContent(BuildOrder(), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.NewGuid().ToString() } } }; var response = await _httpClient.PutAsync("api/orders/cancel", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } [Fact] public async Task ShipWithEmptyGuidFails() { // Act var content = new StringContent(BuildOrder(), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.Empty.ToString() } } }; var response = await _httpClient.PutAsync("api/orders/ship", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task ShipNonExistentOrderFails() { // Act var content = new StringContent(BuildOrder(), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.NewGuid().ToString() } } }; var response = await _httpClient.PutAsync("api/orders/ship", content, TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } [Fact] public async Task GetAllOrdersCardType() { // Act 1 var response = await _httpClient.GetAsync("api/orders/cardtypes", TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task GetStoredOrdersWithOrderId() { // Act var response = await _httpClient.GetAsync("api/orders/1", TestContext.Current.CancellationToken); var responseStatus = response.StatusCode; // Assert Assert.Equal("NotFound", responseStatus.ToString()); } [Fact] public async Task AddNewEmptyOrder() { // Act var content = new StringContent(JsonSerializer.Serialize(new Order()), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.Empty.ToString() } } }; var response = await _httpClient.PostAsync("api/orders", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task AddNewOrder() { // Act var item = new BasketItem { Id = "1", ProductId = 12, ProductName = "Test", UnitPrice = 10, OldUnitPrice = 9, Quantity = 1, PictureUrl = null }; var cardExpirationDate = Convert.ToDateTime("2023-12-22T12:34:24.334Z"); var OrderRequest = new CreateOrderRequest("1", "TestUser", null, null, null, null, null, "XXXXXXXXXXXX0005", "Test User", cardExpirationDate, "test buyer", 1, null, new List { item }); var content = new StringContent(JsonSerializer.Serialize(OrderRequest), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.NewGuid().ToString() } } }; var response = await _httpClient.PostAsync("api/orders", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task PostDraftOrder() { // Act var item = new BasketItem { Id = "1", ProductId = 12, ProductName = "Test", UnitPrice = 10, OldUnitPrice = 9, Quantity = 1, PictureUrl = null }; var bodyContent = new CustomerBasket("1", new List { item }); var content = new StringContent(JsonSerializer.Serialize(bodyContent), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.NewGuid().ToString() } } }; var response = await _httpClient.PostAsync("api/orders/draft", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task CreateOrderDraftSucceeds() { var payload = FakeOrderDraftCommand(); var content = new StringContent(JsonSerializer.Serialize(FakeOrderDraftCommand()), UTF8Encoding.UTF8, "application/json") { Headers = { { "x-requestid", Guid.NewGuid().ToString() } } }; var response = await _httpClient.PostAsync("api/orders/draft", content, TestContext.Current.CancellationToken); var s = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var responseData = JsonSerializer.Deserialize(s, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(payload.Items.Count(), responseData.OrderItems.Count()); Assert.Equal(payload.Items.Sum(o => o.Quantity * o.UnitPrice), responseData.Total); AssertThatOrderItemsAreTheSameAsRequestPayloadItems(payload, responseData); } private CreateOrderDraftCommand FakeOrderDraftCommand() { return new CreateOrderDraftCommand( BuyerId: Guid.NewGuid().ToString(), new List() { new BasketItem() { Id = Guid.NewGuid().ToString(), ProductId = 1, ProductName = "Test Product 1", UnitPrice = 10.2m, OldUnitPrice = 9.8m, Quantity = 2, PictureUrl = Guid.NewGuid().ToString(), } }); } private static void AssertThatOrderItemsAreTheSameAsRequestPayloadItems(CreateOrderDraftCommand payload, OrderDraftDTO responseData) { // check that OrderItems contain all product Ids from the payload var payloadItemsProductIds = payload.Items.Select(x => x.ProductId); var orderItemsProductIds = responseData.OrderItems.Select(x => x.ProductId); Assert.All(orderItemsProductIds, orderItemProdId => payloadItemsProductIds.Contains(orderItemProdId)); // TODO: might need to add more asserts in here } string BuildOrder() { var order = new { OrderNumber = "-1" }; return JsonSerializer.Serialize(order); } } ================================================ FILE: tests/Ordering.UnitTests/Application/IdentifiedCommandHandlerTest.cs ================================================ namespace eShop.Ordering.UnitTests.Application; [TestClass] public class IdentifiedCommandHandlerTest { private readonly IRequestManager _requestManager; private readonly IMediator _mediator; private readonly ILogger> _loggerMock; public IdentifiedCommandHandlerTest() { _requestManager = Substitute.For(); _mediator = Substitute.For(); _loggerMock = Substitute.For>>(); } [TestMethod] public async Task Handler_sends_command_when_order_no_exists() { // Arrange var fakeGuid = Guid.NewGuid(); var fakeOrderCmd = new IdentifiedCommand(FakeOrderRequest(), fakeGuid); _requestManager.ExistAsync(Arg.Any()) .Returns(Task.FromResult(false)); _mediator.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var handler = new CreateOrderIdentifiedCommandHandler(_mediator, _requestManager, _loggerMock); var result = await handler.Handle(fakeOrderCmd, CancellationToken.None); // Assert Assert.IsTrue(result); await _mediator.Received().Send(Arg.Any>(), default); } [TestMethod] public async Task Handler_sends_no_command_when_order_already_exists() { // Arrange var fakeGuid = Guid.NewGuid(); var fakeOrderCmd = new IdentifiedCommand(FakeOrderRequest(), fakeGuid); _requestManager.ExistAsync(Arg.Any()) .Returns(Task.FromResult(true)); _mediator.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var handler = new CreateOrderIdentifiedCommandHandler(_mediator, _requestManager, _loggerMock); var result = await handler.Handle(fakeOrderCmd, CancellationToken.None); // Assert await _mediator.DidNotReceive().Send(Arg.Any>(), default); } private CreateOrderCommand FakeOrderRequest(Dictionary args = null) { return new CreateOrderCommand( new List(), userId: args != null && args.ContainsKey("userId") ? (string)args["userId"] : null, userName: args != null && args.ContainsKey("userName") ? (string)args["userName"] : null, city: args != null && args.ContainsKey("city") ? (string)args["city"] : null, street: args != null && args.ContainsKey("street") ? (string)args["street"] : null, state: args != null && args.ContainsKey("state") ? (string)args["state"] : null, country: args != null && args.ContainsKey("country") ? (string)args["country"] : null, zipcode: args != null && args.ContainsKey("zipcode") ? (string)args["zipcode"] : null, cardNumber: args != null && args.ContainsKey("cardNumber") ? (string)args["cardNumber"] : "1234", cardExpiration: args != null && args.ContainsKey("cardExpiration") ? (DateTime)args["cardExpiration"] : DateTime.MinValue, cardSecurityNumber: args != null && args.ContainsKey("cardSecurityNumber") ? (string)args["cardSecurityNumber"] : "123", cardHolderName: args != null && args.ContainsKey("cardHolderName") ? (string)args["cardHolderName"] : "XXX", cardTypeId: args != null && args.ContainsKey("cardTypeId") ? (int)args["cardTypeId"] : 0); } } ================================================ FILE: tests/Ordering.UnitTests/Application/NewOrderCommandHandlerTest.cs ================================================ using eShop.Ordering.API.Application.IntegrationEvents; using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; namespace eShop.Ordering.UnitTests.Application; [TestClass] public class NewOrderRequestHandlerTest { private readonly IOrderRepository _orderRepositoryMock; private readonly IIdentityService _identityServiceMock; private readonly IMediator _mediator; private readonly IOrderingIntegrationEventService _orderingIntegrationEventService; public NewOrderRequestHandlerTest() { _orderRepositoryMock = Substitute.For(); _identityServiceMock = Substitute.For(); _orderingIntegrationEventService = Substitute.For(); _mediator = Substitute.For(); } [TestMethod] public async Task Handle_return_false_if_order_is_not_persisted() { var buyerId = "1234"; var fakeOrderCmd = FakeOrderRequestWithBuyer(new Dictionary { ["cardExpiration"] = DateTime.UtcNow.AddYears(1) }); _orderRepositoryMock.GetAsync(Arg.Any()) .Returns(Task.FromResult(FakeOrder())); _orderRepositoryMock.UnitOfWork.SaveChangesAsync(default) .Returns(Task.FromResult(1)); _identityServiceMock.GetUserIdentity().Returns(buyerId); var LoggerMock = Substitute.For>(); //Act var handler = new CreateOrderCommandHandler(_mediator, _orderingIntegrationEventService, _orderRepositoryMock, _identityServiceMock, LoggerMock); var cltToken = new CancellationToken(); var result = await handler.Handle(fakeOrderCmd, cltToken); //Assert Assert.IsFalse(result); } [TestMethod] public void Handle_throws_exception_when_no_buyerId() { //Assert Assert.ThrowsExactly(() => new Buyer(string.Empty, string.Empty)); } private Buyer FakeBuyer() { return new Buyer(Guid.NewGuid().ToString(), "1"); } private Order FakeOrder() { return new Order("1", "fakeName", new Address("street", "city", "state", "country", "zipcode"), 1, "12", "111", "fakeName", DateTime.UtcNow.AddYears(1)); } private CreateOrderCommand FakeOrderRequestWithBuyer(Dictionary args = null) { return new CreateOrderCommand( new List(), userId: args != null && args.ContainsKey("userId") ? (string)args["userId"] : null, userName: args != null && args.ContainsKey("userName") ? (string)args["userName"] : null, city: args != null && args.ContainsKey("city") ? (string)args["city"] : null, street: args != null && args.ContainsKey("street") ? (string)args["street"] : null, state: args != null && args.ContainsKey("state") ? (string)args["state"] : null, country: args != null && args.ContainsKey("country") ? (string)args["country"] : null, zipcode: args != null && args.ContainsKey("zipcode") ? (string)args["zipcode"] : null, cardNumber: args != null && args.ContainsKey("cardNumber") ? (string)args["cardNumber"] : "1234", cardExpiration: args != null && args.ContainsKey("cardExpiration") ? (DateTime)args["cardExpiration"] : DateTime.MinValue, cardSecurityNumber: args != null && args.ContainsKey("cardSecurityNumber") ? (string)args["cardSecurityNumber"] : "123", cardHolderName: args != null && args.ContainsKey("cardHolderName") ? (string)args["cardHolderName"] : "XXX", cardTypeId: args != null && args.ContainsKey("cardTypeId") ? (int)args["cardTypeId"] : 0); } } ================================================ FILE: tests/Ordering.UnitTests/Application/OrdersWebApiTest.cs ================================================ namespace eShop.Ordering.UnitTests.Application; using Microsoft.AspNetCore.Http.HttpResults; using eShop.Ordering.API.Application.Queries; using Order = eShop.Ordering.API.Application.Queries.Order; using NSubstitute.ExceptionExtensions; [TestClass] public class OrdersWebApiTest { private readonly IMediator _mediatorMock; private readonly IOrderQueries _orderQueriesMock; private readonly IIdentityService _identityServiceMock; private readonly ILogger _loggerMock; public OrdersWebApiTest() { _mediatorMock = Substitute.For(); _orderQueriesMock = Substitute.For(); _identityServiceMock = Substitute.For(); _loggerMock = Substitute.For>(); } [TestMethod] public async Task Cancel_order_with_requestId_success() { // Arrange _mediatorMock.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.CancelOrderAsync(Guid.NewGuid(), new CancelOrderCommand(1), orderServices); // Assert Assert.IsInstanceOfType(result.Result); } [TestMethod] public async Task Cancel_order_bad_request() { // Arrange _mediatorMock.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.CancelOrderAsync(Guid.Empty, new CancelOrderCommand(1), orderServices); // Assert Assert.IsInstanceOfType>(result.Result); } [TestMethod] public async Task Ship_order_with_requestId_success() { // Arrange _mediatorMock.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.ShipOrderAsync(Guid.NewGuid(), new ShipOrderCommand(1), orderServices); // Assert Assert.IsInstanceOfType(result.Result); } [TestMethod] public async Task Ship_order_bad_request() { // Arrange _mediatorMock.Send(Arg.Any>(), default) .Returns(Task.FromResult(true)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.ShipOrderAsync(Guid.Empty, new ShipOrderCommand(1), orderServices); // Assert Assert.IsInstanceOfType>(result.Result); } [TestMethod] public async Task Get_orders_success() { // Arrange var fakeDynamicResult = Enumerable.Empty(); _identityServiceMock.GetUserIdentity() .Returns(Guid.NewGuid().ToString()); _orderQueriesMock.GetOrdersFromUserAsync(Guid.NewGuid().ToString()) .Returns(Task.FromResult(fakeDynamicResult)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.GetOrdersByUserAsync(orderServices); // Assert Assert.IsInstanceOfType>>(result); } [TestMethod] public async Task Get_order_success() { // Arrange var fakeOrderId = 123; var fakeDynamicResult = new Order(); _orderQueriesMock.GetOrderAsync(Arg.Any()) .Returns(Task.FromResult(fakeDynamicResult)); // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.GetOrderAsync(fakeOrderId, orderServices); // Assert Assert.IsInstanceOfType>(result.Result); Assert.AreSame(fakeDynamicResult, ((Ok)result.Result).Value); } [TestMethod] public async Task Get_order_fails() { // Arrange var fakeOrderId = 123; #pragma warning disable NS5003 _orderQueriesMock.GetOrderAsync(Arg.Any()) .Throws(new KeyNotFoundException()); #pragma warning restore NS5003 // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock); var result = await OrdersApi.GetOrderAsync(fakeOrderId, orderServices); // Assert Assert.IsInstanceOfType(result.Result); } [TestMethod] public async Task Get_cardTypes_success() { // Arrange var fakeDynamicResult = Enumerable.Empty(); _orderQueriesMock.GetCardTypesAsync() .Returns(Task.FromResult(fakeDynamicResult)); // Act var result = await OrdersApi.GetCardTypesAsync(_orderQueriesMock); // Assert Assert.IsInstanceOfType>>(result); Assert.AreSame(fakeDynamicResult, result.Value); } } ================================================ FILE: tests/Ordering.UnitTests/Application/SetStockRejectedOrderStatusCommandTest.cs ================================================ using System.Text.Json; namespace eShop.Ordering.UnitTests.Application; [TestClass] public class SetStockRejectedOrderStatusCommandTest { [TestMethod] public void Set_Stock_Rejected_OrderStatusCommand_Check_Serialization() { // Arrange var command = new SetStockRejectedOrderStatusCommand(123, new List { 1, 2, 3 }); // Act var json = JsonSerializer.Serialize(command); var deserializedCommand = JsonSerializer.Deserialize(json); //Assert Assert.AreEqual(command.OrderNumber, deserializedCommand.OrderNumber); //Assert for List Assert.IsNotNull(deserializedCommand.OrderStockItems); Assert.HasCount(command.OrderStockItems.Count, deserializedCommand.OrderStockItems); for (int i = 0; i < command.OrderStockItems.Count; i++) { Assert.AreEqual(command.OrderStockItems[i], deserializedCommand.OrderStockItems[i]); } } } ================================================ FILE: tests/Ordering.UnitTests/Builders.cs ================================================ using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; namespace eShop.Ordering.UnitTests.Domain; public class AddressBuilder { public Address Build() { return new Address("street", "city", "state", "country", "zipcode"); } } public class OrderBuilder { private readonly Order order; public OrderBuilder(Address address) { order = new Order( "userId", "fakeName", address, cardTypeId: 5, cardNumber: "12", cardSecurityNumber: "123", cardHolderName: "name", cardExpiration: DateTime.UtcNow); } public OrderBuilder AddOne( int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1) { order.AddOrderItem(productId, productName, unitPrice, discount, pictureUrl, units); return this; } public Order Build() { return order; } } ================================================ FILE: tests/Ordering.UnitTests/Domain/BuyerAggregateTest.cs ================================================ namespace eShop.Ordering.UnitTests.Domain; [TestClass] public class BuyerAggregateTest { public BuyerAggregateTest() { } [TestMethod] public void Create_buyer_item_success() { //Arrange var identity = new Guid().ToString(); var name = "fakeUser"; //Act var fakeBuyerItem = new Buyer(identity, name); //Assert Assert.IsNotNull(fakeBuyerItem); } [TestMethod] public void Create_buyer_item_fail() { //Arrange var identity = string.Empty; var name = "fakeUser"; //Act - Assert Assert.ThrowsExactly(() => new Buyer(identity, name)); } [TestMethod] public void add_payment_success() { //Arrange var cardTypeId = 1; var alias = "fakeAlias"; var cardNumber = "124"; var securityNumber = "1234"; var cardHolderName = "FakeHolderNAme"; var expiration = DateTime.UtcNow.AddYears(1); var orderId = 1; var name = "fakeUser"; var identity = new Guid().ToString(); var fakeBuyerItem = new Buyer(identity, name); //Act var result = fakeBuyerItem.VerifyOrAddPaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration, orderId); //Assert Assert.IsNotNull(result); } [TestMethod] public void create_payment_method_success() { //Arrange var cardTypeId = 1; var alias = "fakeAlias"; var cardNumber = "124"; var securityNumber = "1234"; var cardHolderName = "FakeHolderNAme"; var expiration = DateTime.UtcNow.AddYears(1); var fakePaymentMethod = new PaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration); //Act var result = new PaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration); //Assert Assert.IsNotNull(result); } [TestMethod] public void create_payment_method_expiration_fail() { //Arrange var cardTypeId = 1; var alias = "fakeAlias"; var cardNumber = "124"; var securityNumber = "1234"; var cardHolderName = "FakeHolderNAme"; var expiration = DateTime.UtcNow.AddYears(-1); //Act - Assert Assert.ThrowsExactly(() => new PaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration)); } [TestMethod] public void payment_method_isEqualTo() { //Arrange var cardTypeId = 1; var alias = "fakeAlias"; var cardNumber = "124"; var securityNumber = "1234"; var cardHolderName = "FakeHolderNAme"; var expiration = DateTime.UtcNow.AddYears(1); //Act var fakePaymentMethod = new PaymentMethod(cardTypeId, alias, cardNumber, securityNumber, cardHolderName, expiration); var result = fakePaymentMethod.IsEqualTo(cardTypeId, cardNumber, expiration); //Assert Assert.IsTrue(result); } [TestMethod] public void Add_new_PaymentMethod_raises_new_event() { //Arrange var alias = "fakeAlias"; var orderId = 1; var cardTypeId = 5; var cardNumber = "12"; var cardSecurityNumber = "123"; var cardHolderName = "FakeName"; var cardExpiration = DateTime.UtcNow.AddYears(1); var expectedResult = 1; var name = "fakeUser"; //Act var fakeBuyer = new Buyer(Guid.NewGuid().ToString(), name); fakeBuyer.VerifyOrAddPaymentMethod(cardTypeId, alias, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration, orderId); //Assert Assert.HasCount(expectedResult, fakeBuyer.DomainEvents); } } ================================================ FILE: tests/Ordering.UnitTests/Domain/OrderAggregateTest.cs ================================================ namespace eShop.Ordering.UnitTests.Domain; using eShop.Ordering.Domain.AggregatesModel.OrderAggregate; using eShop.Ordering.UnitTests.Domain; [TestClass] public class OrderAggregateTest { public OrderAggregateTest() { } [TestMethod] public void Create_order_item_success() { //Arrange var productId = 1; var productName = "FakeProductName"; var unitPrice = 12; var discount = 15; var pictureUrl = "FakeUrl"; var units = 5; //Act var fakeOrderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units); //Assert Assert.IsNotNull(fakeOrderItem); } [TestMethod] public void Invalid_number_of_units() { //Arrange var productId = 1; var productName = "FakeProductName"; var unitPrice = 12; var discount = 15; var pictureUrl = "FakeUrl"; var units = -1; //Act - Assert Assert.ThrowsExactly(() => new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units)); } [TestMethod] public void Invalid_total_of_order_item_lower_than_discount_applied() { //Arrange var productId = 1; var productName = "FakeProductName"; var unitPrice = 12; var discount = 15; var pictureUrl = "FakeUrl"; var units = 1; //Act - Assert Assert.ThrowsExactly(() => new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units)); } [TestMethod] public void Invalid_discount_setting() { //Arrange var productId = 1; var productName = "FakeProductName"; var unitPrice = 12; var discount = 15; var pictureUrl = "FakeUrl"; var units = 5; //Act var fakeOrderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units); //Assert Assert.ThrowsExactly(() => fakeOrderItem.SetNewDiscount(-1)); } [TestMethod] public void Invalid_units_setting() { //Arrange var productId = 1; var productName = "FakeProductName"; var unitPrice = 12; var discount = 15; var pictureUrl = "FakeUrl"; var units = 5; //Act var fakeOrderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units); //Assert Assert.ThrowsExactly(() => fakeOrderItem.AddUnits(-1)); } [TestMethod] public void when_add_two_times_on_the_same_item_then_the_total_of_order_should_be_the_sum_of_the_two_items() { var address = new AddressBuilder().Build(); var order = new OrderBuilder(address) .AddOne(1, "cup", 10.0m, 0, string.Empty) .AddOne(1, "cup", 10.0m, 0, string.Empty) .Build(); Assert.AreEqual(20.0m, order.GetTotal()); } [TestMethod] public void Add_new_Order_raises_new_event() { //Arrange var street = "fakeStreet"; var city = "FakeCity"; var state = "fakeState"; var country = "fakeCountry"; var zipcode = "FakeZipCode"; var cardTypeId = 5; var cardNumber = "12"; var cardSecurityNumber = "123"; var cardHolderName = "FakeName"; var cardExpiration = DateTime.UtcNow.AddYears(1); var expectedResult = 1; //Act var fakeOrder = new Order("1", "fakeName", new Address(street, city, state, country, zipcode), cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); //Assert Assert.HasCount(expectedResult, fakeOrder.DomainEvents); } [TestMethod] public void Add_event_Order_explicitly_raises_new_event() { //Arrange var street = "fakeStreet"; var city = "FakeCity"; var state = "fakeState"; var country = "fakeCountry"; var zipcode = "FakeZipCode"; var cardTypeId = 5; var cardNumber = "12"; var cardSecurityNumber = "123"; var cardHolderName = "FakeName"; var cardExpiration = DateTime.UtcNow.AddYears(1); var expectedResult = 2; //Act var fakeOrder = new Order("1", "fakeName", new Address(street, city, state, country, zipcode), cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); fakeOrder.AddDomainEvent(new OrderStartedDomainEvent(fakeOrder, "fakeName", "1", cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration)); //Assert Assert.HasCount(expectedResult, fakeOrder.DomainEvents); } [TestMethod] public void Remove_event_Order_explicitly() { //Arrange var street = "fakeStreet"; var city = "FakeCity"; var state = "fakeState"; var country = "fakeCountry"; var zipcode = "FakeZipCode"; var cardTypeId = 5; var cardNumber = "12"; var cardSecurityNumber = "123"; var cardHolderName = "FakeName"; var cardExpiration = DateTime.UtcNow.AddYears(1); var fakeOrder = new Order("1", "fakeName", new Address(street, city, state, country, zipcode), cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); var @fakeEvent = new OrderStartedDomainEvent(fakeOrder, "1", "fakeName", cardTypeId, cardNumber, cardSecurityNumber, cardHolderName, cardExpiration); var expectedResult = 1; //Act fakeOrder.AddDomainEvent(@fakeEvent); fakeOrder.RemoveDomainEvent(@fakeEvent); //Assert Assert.HasCount(expectedResult, fakeOrder.DomainEvents); } } ================================================ FILE: tests/Ordering.UnitTests/Domain/SeedWork/ValueObjectTests.cs ================================================ namespace eShop.Ordering.UnitTests.Domain.SeedWork; [TestClass] public class ValueObjectTests { [TestMethod] [DynamicData(nameof(EqualValueObjects))] public void Equals_EqualValueObjects_ReturnsTrue(ValueObject instanceA, ValueObject instanceB, string reason) { // Act var result = EqualityComparer.Default.Equals(instanceA, instanceB); // Assert Assert.IsTrue(result, reason); } [TestMethod] [DynamicData(nameof(NonEqualValueObjects))] public void Equals_NonEqualValueObjects_ReturnsFalse(ValueObject instanceA, ValueObject instanceB, string reason) { // Act var result = EqualityComparer.Default.Equals(instanceA, instanceB); // Assert Assert.IsFalse(result, reason); } private static readonly ValueObject APrettyValueObject = new ValueObjectA(1, "2", Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), new ComplexObject(2, "3")); public static IEnumerable EqualValueObjects { get { return new[] { new object[] { null, null, "they should be equal because they are both null" }, new object[] { APrettyValueObject, APrettyValueObject, "they should be equal because they are the same object" }, new object[] { new ValueObjectA(1, "2", Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), new ComplexObject(2, "3")), new ValueObjectA(1, "2", Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), new ComplexObject(2, "3")), "they should be equal because they have equal members" }, new object[] { new ValueObjectA(1, "2", Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), new ComplexObject(2, "3"), notAnEqualityComponent: "xpto"), new ValueObjectA(1, "2", Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), new ComplexObject(2, "3"), notAnEqualityComponent: "xpto2"), "they should be equal because all equality components are equal, even though an additional member was set" }, new object[] { new ValueObjectB(1, "2", 1, 2, 3 ), new ValueObjectB(1, "2", 1, 2, 3 ), "they should be equal because all equality components are equal, including the 'C' list" } }; } } public static IEnumerable NonEqualValueObjects { get { return new[] { new object[] { new ValueObjectA(a: 1, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(2, "3")), new ValueObjectA(a: 2, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(2, "3")), "they should not be equal because the 'A' member on ValueObjectA is different among them" }, new object[] { new ValueObjectA(a: 1, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(2, "3")), new ValueObjectA(a: 1, b: null, c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(2, "3")), "they should not be equal because the 'B' member on ValueObjectA is different among them" }, new object[] { new ValueObjectA(a: 1, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(a: 2, b: "3")), new ValueObjectA(a: 1, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(a: 3, b: "3")), "they should not be equal because the 'A' member on ValueObjectA's 'D' member is different among them" }, new object[] { new ValueObjectA(a: 1, b: "2", c: Guid.Parse("97ea43f0-6fef-4fb7-8c67-9114a7ff6ec0"), d: new ComplexObject(a: 2, b: "3")), new ValueObjectB(a: 1, b: "2"), "they should not be equal because they are not of the same type" }, new object[] { new ValueObjectB(1, "2", 1, 2, 3 ), new ValueObjectB(1, "2", 1, 2, 3, 4 ), "they should be not be equal because the 'C' list contains one additional value" }, new object[] { new ValueObjectB(1, "2", 1, 2, 3, 5 ), new ValueObjectB(1, "2", 1, 2, 3 ), "they should be not be equal because the 'C' list contains one additional value" }, new object[] { new ValueObjectB(1, "2", 1, 2, 3, 5 ), new ValueObjectB(1, "2", 1, 2, 3, 4 ), "they should be not be equal because the 'C' lists are not equal" } }; } } private class ValueObjectA : ValueObject { public ValueObjectA(int a, string b, Guid c, ComplexObject d, string notAnEqualityComponent = null) { A = a; B = b; C = c; D = d; NotAnEqualityComponent = notAnEqualityComponent; } public int A { get; } public string B { get; } public Guid C { get; } public ComplexObject D { get; } public string NotAnEqualityComponent { get; } protected override IEnumerable GetEqualityComponents() { yield return A; yield return B; yield return C; yield return D; } } private class ValueObjectB : ValueObject { public ValueObjectB(int a, string b, params int[] c) { A = a; B = b; C = c.ToList(); } public int A { get; } public string B { get; } public List C { get; } protected override IEnumerable GetEqualityComponents() { yield return A; yield return B; foreach (var c in C) { yield return c; } } } private class ComplexObject : IEquatable { public ComplexObject(int a, string b) { A = a; B = b; } public int A { get; set; } public string B { get; set; } public override bool Equals(object obj) { return Equals(obj as ComplexObject); } public bool Equals(ComplexObject other) { return other != null && A == other.A && B == other.B; } public override int GetHashCode() { return HashCode.Combine(A, B); } } } ================================================ FILE: tests/Ordering.UnitTests/GlobalUsings.cs ================================================ global using System; global using System.Collections.Generic; global using System.Linq; global using System.Threading; global using System.Threading.Tasks; global using MediatR; global using Microsoft.AspNetCore.Mvc; global using eShop.Ordering.API.Application.Commands; global using eShop.Ordering.API.Application.Models; global using eShop.Ordering.API.Infrastructure.Services; global using eShop.Ordering.Domain.AggregatesModel.BuyerAggregate; global using eShop.Ordering.Domain.Events; global using eShop.Ordering.Domain.Exceptions; global using eShop.Ordering.Domain.SeedWork; global using eShop.Ordering.Infrastructure.Idempotency; global using Microsoft.Extensions.Logging; global using NSubstitute; global using eShop.Ordering.UnitTests; global using Microsoft.VisualStudio.TestTools.UnitTesting; [assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] ================================================ FILE: tests/Ordering.UnitTests/Ordering.UnitTests.csproj ================================================  net10.0 false false false Exe all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/README.md ================================================ # eShop Tests This directory contains a collection of unit and functional tests for validating the behavior of various components in the eShop application. **NOTE:** Functional tests in this leverage the Aspire host to spin up test containers and require that Docker be running as a pre-requisite.