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/).


## 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/AppShell.xaml.cs
================================================
using eShop.ClientApp.Services;
using eShop.ClientApp.Views;
namespace eShop.ClientApp;
public partial class AppShell : Shell
{
private readonly INavigationService _navigationService;
public AppShell(INavigationService navigationService)
{
_navigationService = navigationService;
InitializeRouting();
InitializeComponent();
}
protected override async void OnHandlerChanged()
{
base.OnHandlerChanged();
if (Handler is not null)
{
await _navigationService.InitializeAsync();
}
}
private static void InitializeRouting()
{
//Routing.RegisterRoute("Login", typeof(LoginView));
Routing.RegisterRoute("Filter", typeof(FiltersView));
Routing.RegisterRoute("ViewCatalogItem", typeof(CatalogItemView));
Routing.RegisterRoute("Basket", typeof(BasketView));
Routing.RegisterRoute("Settings", typeof(SettingsView));
Routing.RegisterRoute("OrderDetail", typeof(OrderDetailView));
Routing.RegisterRoute("Checkout", typeof(CheckoutView));
}
}
================================================
FILE: src/ClientApp/ClientApp.csproj
================================================
net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0
$(TargetFrameworks);net10.0-windows10.0.19041.0
Exe
eShop.ClientApp
true
true
enable
true
false
$(NoWarn);XC0103
AdventureWorks
com.companyname.eshop
9a85b8a9-4da5-4a12-8e7f-43c05ab266d6
1.0
1
15.0
15.0
21.0
10.0.17763.0
10.0.17763.0
6.5
false
#edeafb
128,128
all
runtime; build; native; contentfiles; analyzers; buildtransitive
MSBuild:Compile
$(RuntimeIdentifiers);android-arm64
Platforms/MacCatalyst/Entitlements.Debug.plist
Platforms/MacCatalyst/Entitlements.Release.plist
true
Platforms\iOS\Entitlements.plist
================================================
FILE: src/ClientApp/ClientApp.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", "ClientApp.csproj", "{A70E29B3-9C96-40F7-839E-7350508D4F33}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClientApp.UnitTests", "..\..\tests\ClientApp.UnitTests\ClientApp.UnitTests.csproj", "{E260CC6D-1695-43AA-918A-B5EF4049A3E9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{96432572-F84E-43FE-99BC-F23638D2D7A5}"
ProjectSection(SolutionItems) = preProject
..\..\.github\workflows\client-app-validation.yml = ..\..\.github\workflows\client-app-validation.yml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A70E29B3-9C96-40F7-839E-7350508D4F33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A70E29B3-9C96-40F7-839E-7350508D4F33}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A70E29B3-9C96-40F7-839E-7350508D4F33}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{A70E29B3-9C96-40F7-839E-7350508D4F33}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A70E29B3-9C96-40F7-839E-7350508D4F33}.Release|Any CPU.Build.0 = Release|Any CPU
{E260CC6D-1695-43AA-918A-B5EF4049A3E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E260CC6D-1695-43AA-918A-B5EF4049A3E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E260CC6D-1695-43AA-918A-B5EF4049A3E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E260CC6D-1695-43AA-918A-B5EF4049A3E9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F27E00AF-AB53-4F7B-B80B-85A22DE6E7A6}
EndGlobalSection
EndGlobal
================================================
FILE: src/ClientApp/Controls/AddBasketButton.xaml
================================================
================================================
FILE: src/ClientApp/Controls/AddBasketButton.xaml.cs
================================================
namespace eShop.ClientApp.Controls;
public partial class AddBasketButton : Grid
{
public AddBasketButton()
{
InitializeComponent();
}
}
================================================
FILE: src/ClientApp/Controls/CustomTabbedPage.cs
================================================
namespace eShop.ClientApp.Controls;
public class CustomTabbedPage : TabbedPage
{
public static BindableProperty BadgeTextProperty =
BindableProperty.CreateAttached("BadgeText", typeof(string), typeof(CustomTabbedPage), default(string));
public static BindableProperty BadgeColorProperty =
BindableProperty.CreateAttached("BadgeColor", typeof(Color), typeof(CustomTabbedPage), Colors.Transparent);
public static string GetBadgeText(BindableObject view)
{
return (string)view.GetValue(BadgeTextProperty);
}
public static void SetBadgeText(BindableObject view, string value)
{
view.SetValue(BadgeTextProperty, value);
}
public static Color GetBadgeColor(BindableObject view)
{
return (Color)view.GetValue(BadgeColorProperty);
}
public static void SetBadgeColor(BindableObject view, Color value)
{
view.SetValue(BadgeColorProperty, value);
}
}
================================================
FILE: src/ClientApp/Controls/ToggleButton.cs
================================================
using System.Windows.Input;
namespace eShop.ClientApp.Controls;
public class ToggleButton : ContentView
{
public static readonly BindableProperty CommandProperty =
BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(ToggleButton));
public static readonly BindableProperty CommandParameterProperty =
BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(ToggleButton));
public static readonly BindableProperty CheckedProperty =
BindableProperty.Create(nameof(Checked), typeof(bool), typeof(ToggleButton), false, BindingMode.TwoWay,
propertyChanged: OnCheckedChanged);
public static readonly BindableProperty AnimateProperty =
BindableProperty.Create(nameof(Animate), typeof(bool), typeof(ToggleButton), false);
public static readonly BindableProperty CheckedImageProperty =
BindableProperty.Create(nameof(CheckedImage), typeof(ImageSource), typeof(ToggleButton));
public static readonly BindableProperty UnCheckedImageProperty =
BindableProperty.Create(nameof(UnCheckedImage), typeof(ImageSource), typeof(ToggleButton));
private ICommand _toggleCommand;
private Image _toggleImage;
public ToggleButton()
{
Initialize();
}
public ICommand Command
{
get => (ICommand)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public object CommandParameter
{
get => GetValue(CommandParameterProperty);
set => SetValue(CommandParameterProperty, value);
}
public bool Checked
{
get => (bool)GetValue(CheckedProperty);
set => SetValue(CheckedProperty, value);
}
public bool Animate
{
get => (bool)GetValue(AnimateProperty);
set => SetValue(CheckedProperty, value);
}
public ImageSource CheckedImage
{
get => (ImageSource)GetValue(CheckedImageProperty);
set => SetValue(CheckedImageProperty, value);
}
public ImageSource UnCheckedImage
{
get => (ImageSource)GetValue(UnCheckedImageProperty);
set => SetValue(UnCheckedImageProperty, value);
}
public ICommand ToggleCommand =>
_toggleCommand ??= new Command(() =>
{
Checked = !Checked;
if (Command != null)
{
Command.Execute(CommandParameter);
}
});
private void Initialize()
{
_toggleImage = new Image();
Animate = true;
GestureRecognizers.Add(new TapGestureRecognizer {Command = ToggleCommand});
_toggleImage.Source = UnCheckedImage;
Content = _toggleImage;
}
protected override void OnParentSet()
{
base.OnParentSet();
_toggleImage.Source = UnCheckedImage;
Content = _toggleImage;
}
private static async void OnCheckedChanged(BindableObject bindable, object oldValue, object newValue)
{
var toggleButton = (ToggleButton)bindable;
if (Equals(newValue, null) && !Equals(oldValue, null))
{
return;
}
toggleButton._toggleImage.Source = toggleButton.Checked
? toggleButton.CheckedImage
: toggleButton.UnCheckedImage;
toggleButton.Content = toggleButton._toggleImage;
if (toggleButton.Animate)
{
await toggleButton.ScaleTo(0.9, 50, Easing.Linear);
await Task.Delay(100);
await toggleButton.ScaleTo(1, 50, Easing.Linear);
}
}
}
================================================
FILE: src/ClientApp/Converters/DoesNotHaveCountConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class DoesNotHaveCountConverter : BaseConverterOneWay
{
public override bool DefaultConvertReturnValue { get; set; } = false;
public override bool ConvertFrom(int value, CultureInfo culture)
{
return value <= 0;
}
}
================================================
FILE: src/ClientApp/Converters/DoubleConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class DoubleConverter : BaseConverter
{
public override string DefaultConvertReturnValue { get; set; } = string.Empty;
public override double DefaultConvertBackReturnValue { get; set; } = 0d;
public override double ConvertBackTo(string value, CultureInfo culture)
{
return double.TryParse(value, out var parsed) ? parsed : DefaultConvertBackReturnValue;
}
public override string ConvertFrom(double value, CultureInfo culture)
{
return value.ToString();
}
}
================================================
FILE: src/ClientApp/Converters/FirstValidationErrorConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class FirstValidationErrorConverter : BaseConverterOneWay, string>
{
public override string DefaultConvertReturnValue { get; set; } = string.Empty;
public override string ConvertFrom(IEnumerable value, CultureInfo culture)
{
return value?.FirstOrDefault() ?? DefaultConvertReturnValue;
}
}
================================================
FILE: src/ClientApp/Converters/HasCountConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class HasCountConverter : BaseConverterOneWay
{
public override bool DefaultConvertReturnValue { get; set; } = false;
public override bool ConvertFrom(int value, CultureInfo culture)
{
return value > 0;
}
}
================================================
FILE: src/ClientApp/Converters/ItemsToHeightConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class ItemsToHeightConverter : BaseConverterOneWay
{
private const int ItemHeight = 156;
public override int DefaultConvertReturnValue { get; set; } = ItemHeight;
public override int ConvertFrom(int value, CultureInfo culture)
{
return value * ItemHeight;
}
}
================================================
FILE: src/ClientApp/Converters/WebNavigatedEventArgsConverter.cs
================================================
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace eShop.ClientApp.Converters;
public class WebNavigatedEventArgsConverter : BaseConverterOneWay
{
public override string DefaultConvertReturnValue { get; set; } = string.Empty;
public override string ConvertFrom(WebNavigatedEventArgs value, CultureInfo culture)
{
return value?.Url ?? string.Empty;
}
}
================================================
FILE: src/ClientApp/Converters/WebNavigatingEventArgsConverter.cs
================================================
using System.Globalization;
using Microsoft.Maui.Controls;
namespace eShop.ClientApp.Converters;
public class WebNavigatingEventArgsConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is WebNavigatingEventArgs eventArgs)
{
return eventArgs.Url ?? string.Empty;
}
return string.Empty;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException("ConvertBack is not supported for WebNavigatingEventArgsConverter.");
}
}
================================================
FILE: src/ClientApp/Effects/EntryLineColorEffect.cs
================================================
namespace eShop.ClientApp.Effects;
public class EntryLineColorEffect : RoutingEffect
{
public EntryLineColorEffect()
: base("ClientApp.EntryLineColorEffect")
{
}
}
================================================
FILE: src/ClientApp/Effects/ThemeEffects.cs
================================================
namespace eShop.ClientApp.Effects;
public static class ThemeEffects
{
public static readonly BindableProperty CircleProperty =
BindableProperty.CreateAttached("Circle", typeof(bool), typeof(ThemeEffects), false,
propertyChanged: OnChanged);
public static bool GetCircle(BindableObject view)
{
return (bool)view.GetValue(CircleProperty);
}
public static void SetCircle(BindableObject view, bool circle)
{
view.SetValue(CircleProperty, circle);
}
private static void OnChanged(BindableObject bindable, object oldValue, object newValue)
where TEffect : Effect, new()
{
if (bindable is not View view)
{
return;
}
if (Equals(newValue, default(TProp)))
{
var toRemove = view.Effects.FirstOrDefault(e => e is TEffect);
if (toRemove != null)
{
view.Effects.Remove(toRemove);
}
}
else
{
view.Effects.Add(new TEffect());
}
}
private class CircleEffect : RoutingEffect
{
public CircleEffect()
: base("ClientApp.CircleEffect")
{
}
}
}
================================================
FILE: src/ClientApp/Exceptions/ServiceAuthenticationException.cs
================================================
namespace eShop.ClientApp.Exceptions;
public class ServiceAuthenticationException : Exception
{
public ServiceAuthenticationException()
{
}
public ServiceAuthenticationException(string content)
{
Content = content;
}
public string Content { get; }
}
================================================
FILE: src/ClientApp/Extensions/DictionaryExtensions.cs
================================================
namespace eShop.ClientApp;
public static class DictionaryExtensions
{
public static bool ValueAsBool(this IDictionary dictionary, string key, bool defaultValue = false)
{
return dictionary.ContainsKey(key) && dictionary[key] is bool dictValue
? dictValue
: defaultValue;
}
public static int ValueAsInt(this IDictionary dictionary, string key, int defaultValue = 0)
{
return dictionary.ContainsKey(key) && dictionary[key] is int intValue
? intValue
: defaultValue;
}
public static T ValueAs(this IDictionary dictionary, string key, T defaultValue = default)
{
return dictionary.ContainsKey(key) && dictionary[key] is T value
? value
: defaultValue;
}
}
================================================
FILE: src/ClientApp/Extensions/ICommandExtensions.cs
================================================
using System.Windows.Input;
namespace eShop.ClientApp;
public static class ICommandExtensions
{
public static void AttemptNotifyCanExecuteChanged(this TCommand command)
where TCommand : ICommand
{
if (command is IRelayCommand rc)
{
rc?.NotifyCanExecuteChanged();
}
}
}
================================================
FILE: src/ClientApp/Extensions/VisualElementExtensions.cs
================================================
using System.Linq.Expressions;
using System.Reflection;
namespace eShop.ClientApp;
public static class VisualElementExtensions
{
///
/// Extends VisualElement with a new ColorTo method which provides a higher level approach for animating an elements
/// color.
///
/// A task containing the animation result boolean.
/// VisualElement to process.
/// Expression.
/// end color.
/// The time, in milliseconds, between frames.
/// The number of milliseconds over which to interpolate the animation.
/// The easing function to use to transition in, out, or in and out of the animation.
/// The 1st type parameter.
public static Task ColorTo(this TElement element, Expression> start,
Color end, uint rate = 16, uint length = 250, Easing easing = null)
where TElement : IAnimatable
{
if (element is null)
{
return Task.FromResult(false);
}
easing ??= Easing.Linear;
var member = (MemberExpression)start.Body;
var property = member.Member as PropertyInfo;
var animationName = $"color_to_{property.Name}_{element.GetHashCode()}";
var tcs = new TaskCompletionSource();
var elementStartingColor = (Color)property.GetValue(element);
var transitionAnimation =
new Animation(d => property.SetValue(element, elementStartingColor.Lerp(end, (float)d)), 0d, 1d, easing);
try
{
element.AbortAnimation(animationName);
transitionAnimation.Commit(element, animationName, rate, length, finished: (f, a) => tcs.SetResult(a));
}
catch (InvalidOperationException)
{
}
return tcs.Task;
}
///
/// Extends VisualElement with a new SizeTo method which provides a higher level approach for animating transitions.
///
/// A task containing the animation result boolean.
/// The VisualElement to perform animation on.
/// The animation starting point.
/// The animation ending point.
/// The time, in milliseconds, between frames.
/// The number of milliseconds over which to interpolate the animation.
/// The easing function to use to transition in, out, or in and out of the animation.
/// The 1st type parameter.
public static Task TransitionTo(this TElement element, Expression> start,
double end, uint rate = 16, uint length = 250, Easing easing = null)
where TElement : IAnimatable
{
if (element is null)
{
return Task.FromResult(false);
}
easing ??= Easing.Linear;
var member = (MemberExpression)start.Body;
var property = member.Member as PropertyInfo;
var animationName = $"transition_to_{property.Name}_{element.GetHashCode()}";
var tcs = new TaskCompletionSource();
var elementStartingPosition = (double)property.GetValue(element);
var transitionAnimation =
new Animation(d => property.SetValue(element, d), elementStartingPosition, end, easing);
try
{
element.AbortAnimation(animationName);
transitionAnimation.Commit(element, animationName, rate, length, finished: (f, a) => tcs.SetResult(a));
}
catch (InvalidOperationException)
{
}
return tcs.Task;
}
///