diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2d93fe9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +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 + diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..644fb68 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,48 @@ +name: Playwright Tests for eShop +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' + - 'src/ClientApp/**' + - 'test/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: + global-json-file: global.json + - name: Install Aspire + run: | + dotnet workload update + dotnet workload install aspire + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - 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 diff --git a/.github/workflows/pr-validation-maui.yml b/.github/workflows/pr-validation-maui.yml new file mode 100644 index 0000000..5976dd4 --- /dev/null +++ b/.github/workflows/pr-validation-maui.yml @@ -0,0 +1,42 @@ +name: eShop Pull Request Validation - .NET MAUI + +on: + pull_request: + branches: + - '**' + paths: + - 'src/ClientApp/**' + - 'test/ClientApp.UnitTests/**' + - '.github/workflows/pr-validation-maui.yml' + push: + branches: + - main + paths: + - 'src/ClientApp/**' + - 'test/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 tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj \ No newline at end of file diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 25d430a..320fbb4 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -4,17 +4,21 @@ on: pull_request: paths-ignore: - '**.md' + - 'src/ClientApp/**' + - 'test/ClientApp.UnitTests/**' + - '.github/workflows/pr-validation-maui.yml' push: branches: - main paths-ignore: - '**.md' - + - 'src/ClientApp/**' + - 'test/ClientApp.UnitTests/**' + - '.github/workflows/pr-validation-maui.yml' jobs: test: - runs-on: ubuntu-latest - + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET (global.json) diff --git a/.gitignore b/.gitignore index 104b544..8893976 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,12 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +/user.json +.azure diff --git a/Directory.Build.targets b/Directory.Build.targets index 058a4e8..b1dedc3 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -16,10 +16,6 @@ - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props index f69898c..2c890b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,21 +2,28 @@ true true - 8.0.0 - 8.0.0 - 8.0.0 - 8.0.0 - 8.0.0-preview.2.23619.3 - 2.59.0 + 8.0.3 + 8.3.0 + 8.0.3 + 8.0.0-preview.5.24201.12 + 2.62.0 + 7.0.4 - + + + + + + + + @@ -32,56 +39,50 @@ - + - + - - - + + + - - - - - + - - + - - - - - - - + + + + + + - + - + - + - + - - + + - + - - + + \ No newline at end of file diff --git a/README.md b/README.md index c4807ea..8663fd5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A reference .NET application implementing an eCommerce web site using a services ### Prerequisites - Clone the eShop repository: https://github.com/dotnet/eshopOnAzure -- (Windows only) Install Visual Studio. Visual Studio contains tooling support for .NET Aspire that you will want to have. [Visual Studio 2022 version 17.9 Preview](https://visualstudio.microsoft.com/vs/preview/). +- (Windows only) Install Visual Studio. Visual Studio contains tooling support for .NET Aspire that you will want to have. [Visual Studio 2022 version 17.10 Preview](https://visualstudio.microsoft.com/vs/preview/). - During installation, ensure that the following are selected: - `ASP.NET and web development` workload. - `.NET Aspire SDK` component in `Individual components`. @@ -47,7 +47,37 @@ Now listening on: http://localhost:18848 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). -### Contributing +## 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 [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 Dotnet 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, please read [the contribution documentation](./CONTRIBUTING.md) and [the Code of Conduct](CODE-OF-CONDUCT.md). diff --git a/e2e/AddItemTest.spec.ts b/e2e/AddItemTest.spec.ts new file mode 100644 index 0000000..ac0fcce --- /dev/null +++ b/e2e/AddItemTest.spec.ts @@ -0,0 +1,16 @@ +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); +}); \ No newline at end of file diff --git a/e2e/BrowseItemTest.spec.ts b/e2e/BrowseItemTest.spec.ts new file mode 100644 index 0000000..d702dbd --- /dev/null +++ b/e2e/BrowseItemTest.spec.ts @@ -0,0 +1,13 @@ +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(); +}); \ No newline at end of file diff --git a/e2e/RemoveItemTest.spec.ts b/e2e/RemoveItemTest.spec.ts new file mode 100644 index 0000000..0f1ef4a --- /dev/null +++ b/e2e/RemoveItemTest.spec.ts @@ -0,0 +1,21 @@ +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(); +}); \ No newline at end of file diff --git a/e2e/login.setup.ts b/e2e/login.setup.ts new file mode 100644 index 0000000..2cfcda3 --- /dev/null +++ b/e2e/login.setup.ts @@ -0,0 +1,20 @@ +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 }); +}) diff --git a/eShop.Web.slnf b/eShop.Web.slnf index 657a8c5..101ca8b 100644 --- a/eShop.Web.slnf +++ b/eShop.Web.slnf @@ -2,7 +2,6 @@ "solution": { "path": "eShop.sln", "projects": [ - "src\\AzureServices\\AzureServices.csproj", "src\\Basket.API\\Basket.API.csproj", "src\\Catalog.API\\Catalog.API.csproj", "src\\EventBusServiceBus\\EventBusServiceBus.csproj", diff --git a/eShop.sln b/eShop.sln index 32c058f..5f058fb 100644 --- a/eShop.sln +++ b/eShop.sln @@ -53,8 +53,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ordering.FunctionalTests", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ordering.UnitTests", "tests\Ordering.UnitTests\Ordering.UnitTests.csproj", "{6B3179A3-A527-4CEB-B505-5C53A3B45D4D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureServices", "src\AzureServices\AzureServices.csproj", "{8CF7D96D-20B3-4EB0-93B0-490123136AB5}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventBusServiceBus", "src\EventBusServiceBus\EventBusServiceBus.csproj", "{894CAE67-509D-41C1-A8B3-2C5F8D6CBED3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAppComponents", "src\WebAppComponents\WebAppComponents.csproj", "{0186D030-94C7-446B-A2F7-6E482CC9DEBC}" @@ -147,10 +145,6 @@ Global {6B3179A3-A527-4CEB-B505-5C53A3B45D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU {6B3179A3-A527-4CEB-B505-5C53A3B45D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6B3179A3-A527-4CEB-B505-5C53A3B45D4D}.Release|Any CPU.Build.0 = Release|Any CPU - {8CF7D96D-20B3-4EB0-93B0-490123136AB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8CF7D96D-20B3-4EB0-93B0-490123136AB5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8CF7D96D-20B3-4EB0-93B0-490123136AB5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8CF7D96D-20B3-4EB0-93B0-490123136AB5}.Release|Any CPU.Build.0 = Release|Any CPU {894CAE67-509D-41C1-A8B3-2C5F8D6CBED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {894CAE67-509D-41C1-A8B3-2C5F8D6CBED3}.Debug|Any CPU.Build.0 = Debug|Any CPU {894CAE67-509D-41C1-A8B3-2C5F8D6CBED3}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -199,7 +193,6 @@ Global {B53CD61E-60F2-468A-8748-976B8E85D7FA} = {A857AD10-40FF-4303-BEC2-FF1C58D5735E} {341A5676-0D65-47D5-A483-99511E4C6C65} = {A857AD10-40FF-4303-BEC2-FF1C58D5735E} {6B3179A3-A527-4CEB-B505-5C53A3B45D4D} = {A857AD10-40FF-4303-BEC2-FF1C58D5735E} - {8CF7D96D-20B3-4EB0-93B0-490123136AB5} = {932D8224-11F6-4D07-B109-DA28AD288A63} {894CAE67-509D-41C1-A8B3-2C5F8D6CBED3} = {932D8224-11F6-4D07-B109-DA28AD288A63} {0186D030-94C7-446B-A2F7-6E482CC9DEBC} = {932D8224-11F6-4D07-B109-DA28AD288A63} {66275483-5364-42F9-B7E6-410E6A1B5ECF} = {932D8224-11F6-4D07-B109-DA28AD288A63} diff --git a/global.json b/global.json index f179a6d..be7c83d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "8.0.200", "rollForward": "latestPatch", "allowPrerelease": true } diff --git a/nuget.config b/nuget.config index 3e9fdda..15a0ec2 100644 --- a/nuget.config +++ b/nuget.config @@ -1,4 +1,4 @@ - + @@ -8,6 +8,7 @@ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..deab8d8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "eshop", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eshop", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.42.1", + "@types/node": "^20.11.25", + "dotenv": "^16.4.5" + } + }, + "node_modules/@playwright/test": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", + "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", + "dev": true, + "dependencies": { + "playwright": "1.42.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "20.11.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", + "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", + "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "dev": true, + "dependencies": { + "playwright-core": "1.42.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", + "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5d5626b --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9728afb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,95 @@ +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, + }, +}); diff --git a/src/AzureServices/AppInsightsBuilderExtensions.cs b/src/AzureServices/AppInsightsBuilderExtensions.cs deleted file mode 100644 index 651db04..0000000 --- a/src/AzureServices/AppInsightsBuilderExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json; -using Aspire.Hosting.Azure; - -namespace Aspire.Hosting; - -public static class AppInsightsBuilderExtensions -{ - public static IResourceBuilder AddApplicationInsights(this IDistributedApplicationBuilder builder, string name, string? connectionString = null) - { - var appInsights = new ApplicationInsightsResource(name, connectionString); - return builder.AddResource(appInsights) - .WithAnnotation(new ManifestPublishingCallbackAnnotation(context => - WriteAppInsightsResourceToManifest(context.Writer, appInsights.GetConnectionString()))); - } - - static void WriteAppInsightsResourceToManifest(Utf8JsonWriter jsonWriter, string? connectionString) - { - jsonWriter.WriteString("type", "azure.appinsights.v0"); - if (!string.IsNullOrEmpty(connectionString)) - { - jsonWriter.WriteString("connectionString", connectionString); - } - } -} diff --git a/src/AzureServices/AppInsightsResource.cs b/src/AzureServices/AppInsightsResource.cs deleted file mode 100644 index 7e72b48..0000000 --- a/src/AzureServices/AppInsightsResource.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Aspire.Hosting.Azure; - -public sealed class ApplicationInsightsResource : Resource, IAzureResource, IResourceWithConnectionString -{ - private readonly string? _connectionString; - - public ApplicationInsightsResource(string name, string? connectionString) - : base(name) - { - _connectionString = connectionString; - } - - public string? GetConnectionString() => _connectionString; -} diff --git a/src/AzureServices/AzureServices.csproj b/src/AzureServices/AzureServices.csproj deleted file mode 100644 index 63a1dd5..0000000 --- a/src/AzureServices/AzureServices.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/src/Basket.API/Extensions/Extensions.cs b/src/Basket.API/Extensions/Extensions.cs index fb7c74b..d01da78 100644 --- a/src/Basket.API/Extensions/Extensions.cs +++ b/src/Basket.API/Extensions/Extensions.cs @@ -12,11 +12,11 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddDefaultAuthentication(); - builder.AddRedis("redis"); + builder.AddRedisClient("redis"); builder.Services.AddSingleton(); - builder.AddServiceBusEventBus("EventBus") + builder.AddServiceBusEventBus("eventBus") .AddSubscription() .ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default)); } diff --git a/src/Basket.API/Repositories/RedisBasketRepository.cs b/src/Basket.API/Repositories/RedisBasketRepository.cs index 3c79dee..2dc26f3 100644 --- a/src/Basket.API/Repositories/RedisBasketRepository.cs +++ b/src/Basket.API/Repositories/RedisBasketRepository.cs @@ -9,7 +9,7 @@ public class RedisBasketRepository(ILogger logger, IConne // implementation: - // - /backet/{id} "string" per unique basket + // - /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 diff --git a/src/Catalog.API/AIOptions.cs b/src/Catalog.API/AIOptions.cs index 5f866ba..bbdb10c 100644 --- a/src/Catalog.API/AIOptions.cs +++ b/src/Catalog.API/AIOptions.cs @@ -8,12 +8,6 @@ public class AIOptions public class OpenAIOptions { - /// OpenAI API key for accessing embedding LLM. - public string ApiKey { get; set; } - - /// Optional endpoint for which OpenAI API to access. - public string Endpoint { get; set; } - /// The name of the embedding model to use. /// When using Azure OpenAI, this should be the "Deployment name" of the embedding model. public string EmbeddingName { get; set; } diff --git a/src/Catalog.API/Apis/CatalogApi.cs b/src/Catalog.API/Apis/CatalogApi.cs index 23f5827..582f2eb 100644 --- a/src/Catalog.API/Apis/CatalogApi.cs +++ b/src/Catalog.API/Apis/CatalogApi.cs @@ -47,8 +47,6 @@ public static async Task>, BadRequest(pageIndex, pageSize, totalItems, itemsOnPage)); } @@ -57,7 +55,6 @@ public static async Task>> GetItemsByIds( int[] ids) { var items = await services.Context.CatalogItems.Where(item => ids.Contains(item.Id)).ToListAsync(); - items = ChangeUriPlaceholder(services.Options.Value, items); return TypedResults.Ok(items); } @@ -77,7 +74,6 @@ public static async Task, NotFound, BadRequest>> return TypedResults.NotFound(); } - item.PictureUri = services.Options.Value.PicBaseUrl.Replace("[0]", item.Id.ToString()); return TypedResults.Ok(item); } @@ -99,8 +95,6 @@ public static async Task>> GetItemsByName( .Take(pageSize) .ToListAsync(); - itemsOnPage = ChangeUriPlaceholder(services.Options.Value, itemsOnPage); - return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } @@ -166,8 +160,6 @@ public static async Task, RedirectToRouteHttpResult, .ToListAsync(); } - itemsOnPage = ChangeUriPlaceholder(services.Options.Value, itemsOnPage); - return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } @@ -195,7 +187,6 @@ public static async Task>> GetItemsByBrandAndType .Take(pageSize) .ToListAsync(); - itemsOnPage = ChangeUriPlaceholder(services.Options.Value, itemsOnPage); return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } @@ -222,7 +213,6 @@ public static async Task>> GetItemsByBrandId( .Take(pageSize) .ToListAsync(); - itemsOnPage = ChangeUriPlaceholder(services.Options.Value, itemsOnPage); return TypedResults.Ok(new PaginatedItems(pageIndex, pageSize, totalItems, itemsOnPage)); } @@ -275,7 +265,6 @@ public static async Task CreateItem( Description = product.Description, Name = product.Name, PictureFileName = product.PictureFileName, - PictureUri = product.PictureUri, Price = product.Price, AvailableStock = product.AvailableStock, RestockThreshold = product.RestockThreshold, @@ -305,16 +294,6 @@ public static async Task> DeleteItemById( return TypedResults.NoContent(); } - private static List ChangeUriPlaceholder(CatalogOptions options, List items) - { - foreach (var item in items) - { - item.PictureUri = options.PicBaseUrl.Replace("[0]", item.Id.ToString()); - } - - return items; - } - private static string GetImageMimeTypeFromImageFileExtension(string extension) => extension switch { ".png" => "image/png", diff --git a/src/Catalog.API/Catalog.API.csproj b/src/Catalog.API/Catalog.API.csproj index 43712e8..e02756c 100644 --- a/src/Catalog.API/Catalog.API.csproj +++ b/src/Catalog.API/Catalog.API.csproj @@ -24,7 +24,7 @@ - + @@ -34,6 +34,9 @@ + + + diff --git a/src/Catalog.API/Extensions/CatalogItemExtensions.cs b/src/Catalog.API/Extensions/CatalogItemExtensions.cs deleted file mode 100644 index 61db1a0..0000000 --- a/src/Catalog.API/Extensions/CatalogItemExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace eShop.Catalog.API.Extensions; - -public static class CatalogItemExtensions -{ - public static void FillProductUrl(this CatalogItem item, string picBaseUrl, bool azureStorageEnabled) - { - if (item != null) - { - item.PictureUri = azureStorageEnabled - ? picBaseUrl + item.PictureFileName - : picBaseUrl.Replace("[0]", item.Id.ToString()); - } - } -} diff --git a/src/Catalog.API/Extensions/Extensions.cs b/src/Catalog.API/Extensions/Extensions.cs index 957b5b0..9e42fca 100644 --- a/src/Catalog.API/Extensions/Extensions.cs +++ b/src/Catalog.API/Extensions/Extensions.cs @@ -5,7 +5,7 @@ public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { - builder.AddNpgsqlDbContext("CatalogDB", configureDbContextOptions: dbContextOptionsBuilder => + builder.AddNpgsqlDbContext("catalogdb", configureDbContextOptions: dbContextOptionsBuilder => { dbContextOptionsBuilder.UseNpgsql(builder => { @@ -21,7 +21,7 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) builder.Services.AddTransient(); - builder.AddServiceBusEventBus("EventBus") + builder.AddServiceBusEventBus("eventBus") .AddSubscription() .AddSubscription(); @@ -31,6 +31,11 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) builder.Services.AddOptions() .BindConfiguration("AI"); + if (!string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("openai"))) + { + builder.AddAzureOpenAIClient("openai"); + } + builder.Services.AddSingleton(); } } diff --git a/src/Catalog.API/Infrastructure/EntityConfigurations/CatalogItemEntityTypeConfiguration.cs b/src/Catalog.API/Infrastructure/EntityConfigurations/CatalogItemEntityTypeConfiguration.cs index 4960e46..8812b34 100644 --- a/src/Catalog.API/Infrastructure/EntityConfigurations/CatalogItemEntityTypeConfiguration.cs +++ b/src/Catalog.API/Infrastructure/EntityConfigurations/CatalogItemEntityTypeConfiguration.cs @@ -10,8 +10,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(ci => ci.Name) .HasMaxLength(50); - builder.Ignore(ci => ci.PictureUri); - builder.Property(ci => ci.Embedding) .HasColumnType("vector(1536)"); diff --git a/src/Catalog.API/Model/CatalogItem.cs b/src/Catalog.API/Model/CatalogItem.cs index 12fa77d..e9a7751 100644 --- a/src/Catalog.API/Model/CatalogItem.cs +++ b/src/Catalog.API/Model/CatalogItem.cs @@ -17,8 +17,6 @@ public class CatalogItem public string PictureFileName { get; set; } - public string PictureUri { get; set; } - public int CatalogTypeId { get; set; } public CatalogType CatalogType { get; set; } diff --git a/src/Catalog.API/Services/CatalogAI.cs b/src/Catalog.API/Services/CatalogAI.cs index 11069a6..b23a529 100644 --- a/src/Catalog.API/Services/CatalogAI.cs +++ b/src/Catalog.API/Services/CatalogAI.cs @@ -1,38 +1,29 @@ -using Azure; -using Azure.AI.OpenAI; +using Azure.AI.OpenAI; using Pgvector; namespace eShop.Catalog.API.Services; public sealed class CatalogAI : ICatalogAI { - /// OpenAI API key for accessing embedding LLM. - private readonly string _aiKey; - /// Optional OpenAI API endpoint. - private readonly string _aiEndpoint; - /// The name of the embedding model to use. private readonly string _aiEmbeddingModel; + private readonly OpenAIClient _openAIClient; /// The web host environment. private readonly IWebHostEnvironment _environment; /// Logger for use in AI operations. private readonly ILogger _logger; - public CatalogAI(IOptions options, IWebHostEnvironment environment, ILogger logger) + public CatalogAI(IOptions options, IWebHostEnvironment environment, ILogger logger, OpenAIClient openAIClient = null) { var aiOptions = options.Value; - - _aiKey = aiOptions.OpenAI.ApiKey; - _aiEndpoint = aiOptions.OpenAI.Endpoint; + _openAIClient = openAIClient; _aiEmbeddingModel = aiOptions.OpenAI.EmbeddingName ?? "text-embedding-ada-002"; - IsEnabled = !string.IsNullOrWhiteSpace(_aiKey); - + IsEnabled = _openAIClient != null; _environment = environment; _logger = logger; if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("API Key: {configured}", string.IsNullOrWhiteSpace(_aiKey) ? "Not configured" : "Configured"); _logger.LogInformation("Embedding model: \"{model}\"", _aiEmbeddingModel); } } @@ -54,18 +45,11 @@ public async ValueTask GetEmbeddingAsync(string text) } EmbeddingsOptions options = new(_aiEmbeddingModel, [text]); - return new Vector((await GetAIClient().GetEmbeddingsAsync(options)).Value.Data[0].Embedding); + return new Vector((await _openAIClient.GetEmbeddingsAsync(options)).Value.Data[0].Embedding); } /// Gets an embedding vector for the specified catalog item. public ValueTask GetEmbeddingAsync(CatalogItem item) => IsEnabled ? GetEmbeddingAsync($"{item.Name} {item.Description}") : ValueTask.FromResult(null); - - /// Gets the AI client used for creating embeddings. - private OpenAIClient GetAIClient() => !string.IsNullOrWhiteSpace(_aiKey) ? - !string.IsNullOrWhiteSpace(_aiEndpoint) ? - new OpenAIClient(new Uri(_aiEndpoint), new AzureKeyCredential(_aiKey)) : - new OpenAIClient(_aiKey) : - throw new InvalidOperationException("AI API key not configured"); } diff --git a/src/Catalog.API/appsettings.Development.json b/src/Catalog.API/appsettings.Development.json index 426254f..56ab0b0 100644 --- a/src/Catalog.API/appsettings.Development.json +++ b/src/Catalog.API/appsettings.Development.json @@ -1,8 +1,5 @@ { "ConnectionStrings": { "CatalogDB": "Host=localhost;Database=CatalogDB;Username=postgres;Password=yourWeak(!)Password" - }, - "CatalogOptions": { - "PicBaseUrl": "http://localhost:5222/api/v1/catalog/items/[0]/pic/" } } \ No newline at end of file diff --git a/src/Catalog.API/appsettings.json b/src/Catalog.API/appsettings.json index 2c86e0c..09060dd 100644 --- a/src/Catalog.API/appsettings.json +++ b/src/Catalog.API/appsettings.json @@ -26,7 +26,7 @@ } //"AI": { // "OpenAI": { - // "APIKey": "", // "EmbeddingName": "" // } + //} } diff --git a/src/ClientApp/ClientApp.csproj b/src/ClientApp/ClientApp.csproj index 7e3ed00..a968341 100644 --- a/src/ClientApp/ClientApp.csproj +++ b/src/ClientApp/ClientApp.csproj @@ -18,7 +18,8 @@ true true enable - false + false + $(NoWarn);XC0022;XC0023 Northern Mountains diff --git a/src/EventBusServiceBus/ServiceBusDependencyInjectionExtensions.cs b/src/EventBusServiceBus/ServiceBusDependencyInjectionExtensions.cs index 788c71e..4553c98 100644 --- a/src/EventBusServiceBus/ServiceBusDependencyInjectionExtensions.cs +++ b/src/EventBusServiceBus/ServiceBusDependencyInjectionExtensions.cs @@ -20,7 +20,7 @@ public static IEventBusBuilder AddServiceBusEventBus(this IHostApplicationBuilde { ArgumentNullException.ThrowIfNull(builder); - builder.AddAzureServiceBus(connectionName, o => + builder.AddAzureServiceBusClient(connectionName, o => { o.Tracing = true; o.HealthCheckTopicName = "eshop_event_bus"; diff --git a/src/HybridApp/MauiProgram.cs b/src/HybridApp/MauiProgram.cs index c17e37d..96c1366 100644 --- a/src/HybridApp/MauiProgram.cs +++ b/src/HybridApp/MauiProgram.cs @@ -7,7 +7,7 @@ namespace eShop.HybridApp; public static class MauiProgram { // NOTE: Must have a trailing slash on base URLs to ensure the full BaseAddress URL is used to resolve relative URLs - private static string MobileBffHost = "http://localhost:61632"; + private static string MobileBffHost = "http://localhost:11632"; internal static string MobileBffCatalogBaseUrl = $"{MobileBffHost}/catalog-api/"; public static MauiApp CreateMauiApp() diff --git a/src/Mobile.Bff.Shopping/Mobile.Bff.Shopping.csproj b/src/Mobile.Bff.Shopping/Mobile.Bff.Shopping.csproj index 8b71a2c..81d9149 100644 --- a/src/Mobile.Bff.Shopping/Mobile.Bff.Shopping.csproj +++ b/src/Mobile.Bff.Shopping/Mobile.Bff.Shopping.csproj @@ -2,7 +2,6 @@ net8.0 - mobileshoppingagg diff --git a/src/Mobile.Bff.Shopping/Program.cs b/src/Mobile.Bff.Shopping/Program.cs index a540cd4..742af85 100644 --- a/src/Mobile.Bff.Shopping/Program.cs +++ b/src/Mobile.Bff.Shopping/Program.cs @@ -1,38 +1,13 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -builder.AddApplicationServices(); -var app = builder.Build(); - -app.UseHttpsRedirection(); - -app.MapDefaultEndpoints(); - -const string CatalogApiPrefix = "/api/v1/catalog/"; -var forwardedCatalogApis = new[] -{ - CatalogApiPrefix + "items", - CatalogApiPrefix + "items/by", - CatalogApiPrefix + "items/{id}", - CatalogApiPrefix + "items/by/{name}", +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddServiceDiscoveryDestinationResolver(); - CatalogApiPrefix + "items/withsemanticrelevance/{text}", - - CatalogApiPrefix + "items/type/{typeId}/brand/{brandId?}", - CatalogApiPrefix + "items/type/all/brand/{brandId?}", - CatalogApiPrefix + "catalogTypes", - CatalogApiPrefix + "catalogBrands", - - CatalogApiPrefix + "items/{id}/pic", -}; - -foreach (var forwardedUrl in forwardedCatalogApis) -{ - var mapFromPattern = "/catalog-api" + forwardedUrl; - var mapToTargetPath = forwardedUrl; +var app = builder.Build(); - app.MapForwarder(mapFromPattern, "http://catalog-api", mapToTargetPath); -} +app.MapReverseProxy(); await app.RunAsync(); diff --git a/src/Mobile.Bff.Shopping/Properties/launchSettings.json b/src/Mobile.Bff.Shopping/Properties/launchSettings.json index aa49e3a..6fba4bf 100644 --- a/src/Mobile.Bff.Shopping/Properties/launchSettings.json +++ b/src/Mobile.Bff.Shopping/Properties/launchSettings.json @@ -3,7 +3,7 @@ "http": { "commandName": "Project", "launchBrowser": true, - "applicationUrl": "http://localhost:61632/", + "applicationUrl": "http://localhost:11632/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Mobile.Bff.Shopping/appsettings.json b/src/Mobile.Bff.Shopping/appsettings.json index a168074..2f19513 100644 --- a/src/Mobile.Bff.Shopping/appsettings.json +++ b/src/Mobile.Bff.Shopping/appsettings.json @@ -3,7 +3,133 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", - "System.Net.Http": "Warning" + "System.Net.Http": "Warning", + "Yarp": "Warning" + } + }, + "AllowedHosts": "*", + "ReverseProxy": { + "Routes": { + "route1": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route2": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/by" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route3": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/{id}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route4": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/by/{name}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route5": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/withsemanticrelevance/{text}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route6": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/type/{typeId}/brand/{brandId?}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route7": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/type/all/brand/{brandId?}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route8": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/catalogTypes" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route9": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/catalogBrands" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + }, + "route10": { + "ClusterId": "catalog", + "Match": { + "Path": "/catalog-api/api/v1/catalog/items/{id}/pic" + }, + "Transforms": [ + { + "PathRemovePrefix": "/catalog-api" + } + ] + } + }, + + "Clusters": { + "catalog": { + "Destinations": { + "catalogDestination": { + "Address": "http://catalog-api" + } + } + } } } } diff --git a/src/OrderProcessor/Extensions/Extensions.cs b/src/OrderProcessor/Extensions/Extensions.cs index cb710e2..0b57cf7 100644 --- a/src/OrderProcessor/Extensions/Extensions.cs +++ b/src/OrderProcessor/Extensions/Extensions.cs @@ -8,10 +8,10 @@ public static class Extensions { public static void AddApplicationServices(this IHostApplicationBuilder builder) { - builder.AddServiceBusEventBus("EventBus") + builder.AddServiceBusEventBus("eventBus") .ConfigureJsonOptions(options => options.TypeInfoResolverChain.Add(IntegrationEventContext.Default)); - builder.AddNpgsqlDataSource("OrderingDB"); + builder.AddNpgsqlDataSource("orderingdb"); builder.Services.AddOptions() .BindConfiguration(nameof(BackgroundTaskOptions)); diff --git a/src/Ordering.API/Application/Commands/CreateOrderDraftCommandHandler.cs b/src/Ordering.API/Application/Commands/CreateOrderDraftCommandHandler.cs index f23d39c..1549a80 100644 --- a/src/Ordering.API/Application/Commands/CreateOrderDraftCommandHandler.cs +++ b/src/Ordering.API/Application/Commands/CreateOrderDraftCommandHandler.cs @@ -31,12 +31,12 @@ public static OrderDraftDTO FromOrder(Order order) { OrderItems = order.OrderItems.Select(oi => new OrderItemDTO { - Discount = oi.GetCurrentDiscount(), + Discount = oi.Discount, ProductId = oi.ProductId, - UnitPrice = oi.GetUnitPrice(), - PictureUrl = oi.GetPictureUri(), - Units = oi.GetUnits(), - ProductName = oi.GetOrderItemProductName() + UnitPrice = oi.UnitPrice, + PictureUrl = oi.PictureUrl, + Units = oi.Units, + ProductName = oi.ProductName }), Total = order.GetTotal() }; diff --git a/src/Ordering.API/Application/DomainEventHandlers/OrderCancelledDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/OrderCancelledDomainEventHandler.cs index f70d6a2..d66748f 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/OrderCancelledDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/OrderCancelledDomainEventHandler.cs @@ -25,7 +25,7 @@ public async Task Handle(OrderCancelledDomainEvent domainEvent, CancellationToke OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.Order.Id, OrderStatus.Cancelled); var order = await _orderRepository.GetAsync(domainEvent.Order.Id); - var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value); + var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToCancelledIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); diff --git a/src/Ordering.API/Application/DomainEventHandlers/OrderShippedDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/OrderShippedDomainEventHandler.cs index d94356c..52a22f3 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/OrderShippedDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/OrderShippedDomainEventHandler.cs @@ -25,7 +25,7 @@ public async Task Handle(OrderShippedDomainEvent domainEvent, CancellationToken OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.Order.Id, OrderStatus.Shipped); var order = await _orderRepository.GetAsync(domainEvent.Order.Id); - var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value); + var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToShippedIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); diff --git a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToAwaitingValidationDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToAwaitingValidationDomainEventHandler.cs index d4bc115..2fa272f 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToAwaitingValidationDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToAwaitingValidationDomainEventHandler.cs @@ -25,10 +25,10 @@ public async Task Handle(OrderStatusChangedToAwaitingValidationDomainEvent domai OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.AwaitingValidation); var order = await _orderRepository.GetAsync(domainEvent.OrderId); - var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value); + var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var orderStockList = domainEvent.OrderItems - .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.GetUnits())); + .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.Units)); var integrationEvent = new OrderStatusChangedToAwaitingValidationIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid, orderStockList); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); diff --git a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToPaidDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToPaidDomainEventHandler.cs index 06a8f0b..6e1c899 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToPaidDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToPaidDomainEventHandler.cs @@ -24,10 +24,10 @@ public async Task Handle(OrderStatusChangedToPaidDomainEvent domainEvent, Cancel OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.Paid); var order = await _orderRepository.GetAsync(domainEvent.OrderId); - var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value); + var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var orderStockList = domainEvent.OrderItems - .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.GetUnits())); + .Select(orderItem => new OrderStockItem(orderItem.ProductId, orderItem.Units)); var integrationEvent = new OrderStatusChangedToPaidIntegrationEvent( domainEvent.OrderId, diff --git a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToStockConfirmedDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToStockConfirmedDomainEventHandler.cs index 1947169..7ecf3f6 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToStockConfirmedDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/OrderStatusChangedToStockConfirmedDomainEventHandler.cs @@ -25,7 +25,7 @@ public async Task Handle(OrderStatusChangedToStockConfirmedDomainEvent domainEve OrderingApiTrace.LogOrderStatusUpdated(_logger, domainEvent.OrderId, OrderStatus.StockConfirmed); var order = await _orderRepository.GetAsync(domainEvent.OrderId); - var buyer = await _buyerRepository.FindByIdAsync(order.GetBuyerId.Value); + var buyer = await _buyerRepository.FindByIdAsync(order.BuyerId.Value); var integrationEvent = new OrderStatusChangedToStockConfirmedIntegrationEvent(order.Id, order.OrderStatus, buyer.Name, buyer.IdentityGuid); await _orderingIntegrationEventService.AddAndSaveEventAsync(integrationEvent); diff --git a/src/Ordering.API/Application/DomainEventHandlers/UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs b/src/Ordering.API/Application/DomainEventHandlers/UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs index ab221ef..62fb791 100644 --- a/src/Ordering.API/Application/DomainEventHandlers/UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs +++ b/src/Ordering.API/Application/DomainEventHandlers/UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler.cs @@ -19,8 +19,7 @@ public UpdateOrderWhenBuyerAndPaymentMethodVerifiedDomainEventHandler( public async Task Handle(BuyerAndPaymentMethodVerifiedDomainEvent domainEvent, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(domainEvent.OrderId); - orderToUpdate.SetBuyerId(domainEvent.Buyer.Id); - orderToUpdate.SetPaymentId(domainEvent.Payment.Id); + orderToUpdate.SetPaymentMethodVerified(domainEvent.Buyer.Id, domainEvent.Payment.Id); OrderingApiTrace.LogOrderPaymentMethodUpdated(_logger, domainEvent.OrderId, nameof(domainEvent.Payment), domainEvent.Payment.Id); } } diff --git a/src/Ordering.API/Application/Queries/OrderQueries.cs b/src/Ordering.API/Application/Queries/OrderQueries.cs index e8a47fa..1040913 100644 --- a/src/Ordering.API/Application/Queries/OrderQueries.cs +++ b/src/Ordering.API/Application/Queries/OrderQueries.cs @@ -1,83 +1,53 @@ namespace eShop.Ordering.API.Application.Queries; -public class OrderQueries(NpgsqlDataSource dataSource) +public class OrderQueries(OrderingContext context) : IOrderQueries { public async Task GetOrderAsync(int id) { - using var connection = dataSource.OpenConnection(); - - var result = await connection.QueryAsync(""" - SELECT o."Id" AS ordernumber, o."OrderDate" AS date, o."Description" AS description, o."Address_City" AS city, - o."Address_Country" AS country, o."Address_State" AS state, o."Address_Street" AS street, - o."Address_ZipCode" AS zipcode, o."OrderStatus" AS status, oi."ProductName" AS productname, oi."Units" AS units, - oi."UnitPrice" AS unitprice, oi."PictureUrl" AS pictureurl - FROM ordering.Orders AS o - LEFT JOIN ordering."orderItems" AS oi ON o."Id" = oi."OrderId" - WHERE o."Id" = @id - """, - new { id }); - - if (result.AsList().Count == 0) + var order = await context.Orders + .Include(o => o.OrderItems) + .FirstOrDefaultAsync(o => o.Id == id); + + if (order is null) throw new KeyNotFoundException(); - return MapOrderItems(result); + return new Order + { + ordernumber = order.Id, + date = order.OrderDate, + description = order.Description, + city = order.Address.City, + country = order.Address.Country, + state = order.Address.State, + street = order.Address.Street, + zipcode = order.Address.ZipCode, + status = order.OrderStatus.ToString(), + total = order.GetTotal(), + orderitems = order.OrderItems.Select(oi => new Orderitem + { + productname = oi.ProductName, + units = oi.Units, + unitprice = (double)oi.UnitPrice, + pictureurl = oi.PictureUrl + }).ToList() + }; } public async Task> GetOrdersFromUserAsync(string userId) { - using var connection = dataSource.OpenConnection(); - - return await connection.QueryAsync(""" - SELECT o."Id" AS ordernumber, o."OrderDate" AS date, o."OrderStatus" AS status, SUM(oi."Units" * oi."UnitPrice") AS total - FROM ordering.orders AS o - LEFT JOIN ordering."orderItems" AS oi ON o."Id" = oi."OrderId" - LEFT JOIN ordering.buyers AS ob ON o."BuyerId" = ob."Id" - WHERE ob."IdentityGuid" = @userId - GROUP BY o."Id", o."OrderDate", o."OrderStatus" - ORDER BY o."Id" - """, - new { userId }); - } - - public async Task> GetCardTypesAsync() - { - using var connection = dataSource.OpenConnection(); - - return await connection.QueryAsync("SELECT * FROM ordering.cardtypes"); - } - - private Order MapOrderItems(dynamic result) - { - var order = new Order - { - ordernumber = result[0].ordernumber, - date = result[0].date, - status = result[0].status, - description = result[0].description, - street = result[0].street, - city = result[0].city, - state = result[0].state, - zipcode = result[0].zipcode, - country = result[0].country, - orderitems = new List(), - total = 0 - }; - - foreach (dynamic item in result) - { - var orderitem = new Orderitem + return await context.Orders + .Where(o => o.Buyer.IdentityGuid == userId) + .Select(o => new OrderSummary { - productname = item.productname, - units = item.units, - unitprice = (double)item.unitprice, - pictureurl = item.pictureurl - }; - - order.total += item.units * item.unitprice; - order.orderitems.Add(orderitem); - } - - return order; - } + ordernumber = o.Id, + date = o.OrderDate, + status = o.OrderStatus.ToString(), + total =(double) o.OrderItems.Sum(oi => oi.UnitPrice* oi.Units) + }) + .ToListAsync(); + } + + public async Task> GetCardTypesAsync() => + await context.CardTypes.Select(c=> new CardType { Id = c.Id, Name = c.Name }).ToListAsync(); } diff --git a/src/Ordering.API/Extensions/Extensions.cs b/src/Ordering.API/Extensions/Extensions.cs index 106f288..641d412 100644 --- a/src/Ordering.API/Extensions/Extensions.cs +++ b/src/Ordering.API/Extensions/Extensions.cs @@ -12,7 +12,11 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) // Pooling is disabled because of the following error: // Unhandled exception. System.InvalidOperationException: // The DbContext of type 'OrderingContext' cannot be pooled because it does not have a public constructor accepting a single parameter of type DbContextOptions or has more than one constructor. - builder.AddNpgsqlDbContext("OrderingDB", settings => settings.DbContextPooling = false); + services.AddDbContext(options => + { + options.UseNpgsql(builder.Configuration.GetConnectionString("orderingdb")); + }); + builder.EnrichNpgsqlDbContext(); services.AddMigration(); @@ -21,7 +25,7 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) services.AddTransient(); - builder.AddServiceBusEventBus("EventBus") + builder.AddServiceBusEventBus("eventBus") .AddEventBusSubscriptions(); services.AddHttpContextAccessor(); diff --git a/src/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs b/src/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs index 00e6327..aff7de4 100644 --- a/src/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs +++ b/src/Ordering.Domain/AggregatesModel/OrderAggregate/Order.cs @@ -5,21 +5,19 @@ namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; public class Order : Entity, IAggregateRoot { - // DDD Patterns comment - // Using private fields, allowed since EF Core 1.1, is a much better encapsulation - // aligned with DDD Aggregates and Domain Entities (Instead of properties and property collections) - private DateTime _orderDate; + public DateTime OrderDate { get; private set; } // Address is a Value Object pattern example persisted as EF Core 2.0 owned entity [Required] public Address Address { get; private set; } - public int? GetBuyerId => _buyerId; - private int? _buyerId; + public int? BuyerId { get; private set; } - public OrderStatus OrderStatus { get; private set; } + public Buyer Buyer { get; } - private string _description; + public OrderStatus OrderStatus { get; private set; } + + public string Description { get; private set; } // Draft orders have this set to true. Currently we don't check anywhere the draft status of an Order, but we could do it if needed #pragma warning disable CS0414 // The field 'Order._isDraft' is assigned but its value is never used @@ -31,9 +29,10 @@ public class Order // so OrderItems cannot be added from "outside the AggregateRoot" directly to the collection, // but only through the method OrderAggregateRoot.AddOrderItem() which includes behavior. private readonly List _orderItems; - public IReadOnlyCollection OrderItems => _orderItems; + + public IReadOnlyCollection OrderItems => _orderItems.AsReadOnly(); - private int? _paymentMethodId; + public int? PaymentId { get; private set; } public static Order NewDraft() { @@ -53,10 +52,10 @@ protected Order() public Order(string userId, string userName, Address address, int cardTypeId, string cardNumber, string cardSecurityNumber, string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null) : this() { - _buyerId = buyerId; - _paymentMethodId = paymentMethodId; + BuyerId = buyerId; + PaymentId = paymentMethodId; OrderStatus = OrderStatus.Submitted; - _orderDate = DateTime.UtcNow; + OrderDate = DateTime.UtcNow; Address = address; // Add the OrderStarterDomainEvent to the domain events collection @@ -71,14 +70,12 @@ public Order(string userId, string userName, Address address, int cardTypeId, st // in order to maintain consistency between the whole Aggregate. public void AddOrderItem(int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1) { - var existingOrderForProduct = _orderItems.Where(o => o.ProductId == productId) - .SingleOrDefault(); + var existingOrderForProduct = _orderItems.SingleOrDefault(o => o.ProductId == productId); if (existingOrderForProduct != null) { //if previous line exist modify it with higher discount and units.. - - if (discount > existingOrderForProduct.GetCurrentDiscount()) + if (discount > existingOrderForProduct.Discount) { existingOrderForProduct.SetNewDiscount(discount); } @@ -88,22 +85,17 @@ public void AddOrderItem(int productId, string productName, decimal unitPrice, d else { //add validated new order item - var orderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units); _orderItems.Add(orderItem); } } - public void SetPaymentId(int id) + public void SetPaymentMethodVerified(int buyerId, int paymentId) { - _paymentMethodId = id; + BuyerId = buyerId; + PaymentId = paymentId; } - - public void SetBuyerId(int id) - { - _buyerId = id; - } - + public void SetAwaitingValidationStatus() { if (OrderStatus == OrderStatus.Submitted) @@ -120,7 +112,7 @@ public void SetStockConfirmedStatus() AddDomainEvent(new OrderStatusChangedToStockConfirmedDomainEvent(Id)); OrderStatus = OrderStatus.StockConfirmed; - _description = "All the items were confirmed with available stock."; + Description = "All the items were confirmed with available stock."; } } @@ -131,7 +123,7 @@ public void SetPaidStatus() AddDomainEvent(new OrderStatusChangedToPaidDomainEvent(Id, OrderItems)); OrderStatus = OrderStatus.Paid; - _description = "The payment was performed at a simulated \"American Bank checking bank account ending on XX35071\""; + Description = "The payment was performed at a simulated \"American Bank checking bank account ending on XX35071\""; } } @@ -143,7 +135,7 @@ public void SetShippedStatus() } OrderStatus = OrderStatus.Shipped; - _description = "The order was shipped."; + Description = "The order was shipped."; AddDomainEvent(new OrderShippedDomainEvent(this)); } @@ -156,7 +148,7 @@ public void SetCancelledStatus() } OrderStatus = OrderStatus.Cancelled; - _description = $"The order was cancelled."; + Description = $"The order was cancelled."; AddDomainEvent(new OrderCancelledDomainEvent(this)); } @@ -168,10 +160,10 @@ public void SetCancelledStatusWhenStockIsRejected(IEnumerable orderStockRej var itemsStockRejectedProductNames = OrderItems .Where(c => orderStockRejectedItems.Contains(c.ProductId)) - .Select(c => c.GetOrderItemProductName()); + .Select(c => c.ProductName); var itemsStockRejectedDescription = string.Join(", ", itemsStockRejectedProductNames); - _description = $"The product items don't have stock: ({itemsStockRejectedDescription})."; + Description = $"The product items don't have stock: ({itemsStockRejectedDescription})."; } } @@ -190,8 +182,5 @@ private void StatusChangeException(OrderStatus orderStatusToChange) throw new OrderingDomainException($"Is not possible to change the order status from {OrderStatus} to {orderStatusToChange}."); } - public decimal GetTotal() - { - return _orderItems.Sum(o => o.GetUnits() * o.GetUnitPrice()); - } + public decimal GetTotal() => _orderItems.Sum(o => o.Units * o.UnitPrice); } diff --git a/src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderItem.cs b/src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderItem.cs index 704b098..e021bc8 100644 --- a/src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderItem.cs +++ b/src/Ordering.Domain/AggregatesModel/OrderAggregate/OrderItem.cs @@ -5,21 +5,22 @@ namespace eShop.Ordering.Domain.AggregatesModel.OrderAggregate; public class OrderItem : Entity { - // DDD Patterns comment - // Using private fields, allowed since EF Core 1.1, is a much better encapsulation - // aligned with DDD Aggregates and Domain Entities (Instead of properties and property collections) [Required] - private string _productName; - private string _pictureUrl; - private decimal _unitPrice; - private decimal _discount; - private int _units; + public string ProductName { get; private set; } + + public string PictureUrl { get; private set;} + + public decimal UnitPrice { get; private set;} + + public decimal Discount { get; private set; } + + public int Units { get; private set; } public int ProductId { get; private set; } protected OrderItem() { } - public OrderItem(int productId, string productName, decimal unitPrice, decimal discount, string PictureUrl, int units = 1) + public OrderItem(int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1) { if (units <= 0) { @@ -33,32 +34,13 @@ public OrderItem(int productId, string productName, decimal unitPrice, decimal d ProductId = productId; - _productName = productName; - _unitPrice = unitPrice; - _discount = discount; - _units = units; - _pictureUrl = PictureUrl; + ProductName = productName; + UnitPrice = unitPrice; + Discount = discount; + Units = units; + PictureUrl = pictureUrl; } - - public string GetPictureUri() => _pictureUrl; - - public decimal GetCurrentDiscount() - { - return _discount; - } - - public int GetUnits() - { - return _units; - } - - public decimal GetUnitPrice() - { - return _unitPrice; - } - - public string GetOrderItemProductName() => _productName; - + public void SetNewDiscount(decimal discount) { if (discount < 0) @@ -66,7 +48,7 @@ public void SetNewDiscount(decimal discount) throw new OrderingDomainException("Discount is not valid"); } - _discount = discount; + Discount = discount; } public void AddUnits(int units) @@ -76,6 +58,6 @@ public void AddUnits(int units) throw new OrderingDomainException("Invalid units"); } - _units += units; + Units += units; } } diff --git a/src/Ordering.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs b/src/Ordering.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs index c86ecce..367dd34 100644 --- a/src/Ordering.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs +++ b/src/Ordering.Infrastructure/EntityConfigurations/OrderEntityTypeConfiguration.cs @@ -15,32 +15,22 @@ public void Configure(EntityTypeBuilder orderConfiguration) orderConfiguration .OwnsOne(o => o.Address); - orderConfiguration - .Property("_buyerId") - .HasColumnName("BuyerId"); - - orderConfiguration - .Property("_orderDate") - .HasColumnName("OrderDate"); - orderConfiguration .Property(o => o.OrderStatus) .HasConversion() .HasMaxLength(30); orderConfiguration - .Property("_paymentMethodId") + .Property(o => o.PaymentId) .HasColumnName("PaymentMethodId"); - orderConfiguration.Property("Description"); - orderConfiguration.HasOne() .WithMany() - .HasForeignKey("_paymentMethodId") + .HasForeignKey(o => o.PaymentId) .OnDelete(DeleteBehavior.Restrict); - orderConfiguration.HasOne() + orderConfiguration.HasOne(o => o.Buyer) .WithMany() - .HasForeignKey("_buyerId"); + .HasForeignKey(o => o.BuyerId); } } diff --git a/src/Ordering.Infrastructure/EntityConfigurations/OrderItemEntityTypeConfiguration.cs b/src/Ordering.Infrastructure/EntityConfigurations/OrderItemEntityTypeConfiguration.cs index da03610..e359570 100644 --- a/src/Ordering.Infrastructure/EntityConfigurations/OrderItemEntityTypeConfiguration.cs +++ b/src/Ordering.Infrastructure/EntityConfigurations/OrderItemEntityTypeConfiguration.cs @@ -13,25 +13,5 @@ public void Configure(EntityTypeBuilder orderItemConfiguration) .UseHiLo("orderitemseq"); orderItemConfiguration.Property("OrderId"); - - orderItemConfiguration - .Property("_discount") - .HasColumnName("Discount"); - - orderItemConfiguration - .Property("_productName") - .HasColumnName("ProductName"); - - orderItemConfiguration - .Property("_unitPrice") - .HasColumnName("UnitPrice"); - - orderItemConfiguration - .Property("_units") - .HasColumnName("Units"); - - orderItemConfiguration - .Property("_pictureUrl") - .HasColumnName("PictureUrl"); } } diff --git a/src/WebApp/AIOptions.cs b/src/WebApp/AIOptions.cs index f9ca5db..37c0f82 100644 --- a/src/WebApp/AIOptions.cs +++ b/src/WebApp/AIOptions.cs @@ -8,12 +8,6 @@ public class AIOptions public class OpenAIOptions { - /// OpenAI API key for accessing embedding LLM. - public string? ApiKey { get; set; } - - /// Optional endpoint for which OpenAI API to access. - public string? Endpoint { get; set; } - /// The name of the chat model to use. /// When using Azure OpenAI, this should be the "Deployment name" of the chat model. public string ChatModel { get; set; } = "gpt-3.5-turbo-16k"; diff --git a/src/WebApp/Components/Chatbot/ChatState.cs b/src/WebApp/Components/Chatbot/ChatState.cs index 2aedff0..ae45024 100644 --- a/src/WebApp/Components/Chatbot/ChatState.cs +++ b/src/WebApp/Components/Chatbot/ChatState.cs @@ -17,14 +17,16 @@ public class ChatState private readonly NavigationManager _navigationManager; private readonly ILogger _logger; private readonly Kernel _kernel; + private readonly IProductImageUrlProvider _productImages; private readonly OpenAIPromptExecutionSettings _aiSettings = new() { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions }; - public ChatState(CatalogService catalogService, BasketState basketState, ClaimsPrincipal user, NavigationManager nav, Kernel kernel, ILoggerFactory loggerFactory) + public ChatState(CatalogService catalogService, BasketState basketState, ClaimsPrincipal user, NavigationManager nav, IProductImageUrlProvider productImages, Kernel kernel, ILoggerFactory loggerFactory) { _catalogService = catalogService; _basketState = basketState; _user = user; _navigationManager = nav; + _productImages = productImages; _logger = loggerFactory.CreateLogger(typeof(ChatState)); if (_logger.IsEnabled(LogLevel.Debug)) @@ -105,6 +107,11 @@ static string GetValue(IEnumerable claims, string claimType) => try { var results = await chatState._catalogService.GetCatalogItemsWithSemanticRelevance(0, 8, productDescription!); + for (int i = 0; i < results.Data.Count; i++) + { + results.Data[i] = results.Data[i] with { PictureUrl = chatState._productImages.GetProductImageUrl(results.Data[i].Id) }; + } + return JsonSerializer.Serialize(results); } catch (HttpRequestException e) diff --git a/src/WebApp/Components/Chatbot/Chatbot.razor b/src/WebApp/Components/Chatbot/Chatbot.razor index 649dca5..4d433c8 100644 --- a/src/WebApp/Components/Chatbot/Chatbot.razor +++ b/src/WebApp/Components/Chatbot/Chatbot.razor @@ -6,6 +6,7 @@ @inject IJSRuntime JS @inject NavigationManager Nav @inject CatalogService CatalogService +@inject IProductImageUrlProvider ProductImages @inject BasketState BasketState @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILoggerFactory LoggerFactory @@ -27,7 +28,7 @@ } else if (missingConfiguration) { -

The chatbot is missing required configuration. Please edit appsettings.json in the WebApp project. You'll need an API key to enable AI features.

+

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

} @if (thinking) @@ -57,7 +58,7 @@ if (kernel is not null) { AuthenticationState auth = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - chatState = new ChatState(CatalogService, BasketState, auth.User, Nav, kernel, LoggerFactory); + chatState = new ChatState(CatalogService, BasketState, auth.User, Nav, ProductImages, kernel, LoggerFactory); } else { diff --git a/src/WebApp/Components/Chatbot/Chatbot.razor.css b/src/WebApp/Components/Chatbot/Chatbot.razor.css index 27ed15b..e4f329c 100644 --- a/src/WebApp/Components/Chatbot/Chatbot.razor.css +++ b/src/WebApp/Components/Chatbot/Chatbot.razor.css @@ -1,7 +1,7 @@ .floating-pane { position: fixed; width: 25rem; - height: 40rem; + height: 35rem; right: 3rem; bottom: 3rem; border: 1px solid silver; diff --git a/src/WebApp/Components/Chatbot/MessageProcessor.cs b/src/WebApp/Components/Chatbot/MessageProcessor.cs index 5a32b7a..673c932 100644 --- a/src/WebApp/Components/Chatbot/MessageProcessor.cs +++ b/src/WebApp/Components/Chatbot/MessageProcessor.cs @@ -5,7 +5,7 @@ namespace eShop.WebApp.Chatbot; -public static class MessageProcessor +public static partial class MessageProcessor { public static MarkupString AllowImages(string message) { @@ -17,7 +17,7 @@ public static MarkupString AllowImages(string message) var prevEnd = 0; message = message.Replace("<", "<").Replace(">", ">"); - foreach (Match match in Regex.Matches(message, @"\!?\[([^\]]+)\]\s*\((http[^\)]+)\)")) + foreach (Match match in FindMarkdownImages().Matches(message)) { var contentToHere = message.Substring(prevEnd, match.Index - prevEnd); result.Append(HtmlEncoder.Default.Encode(contentToHere)); @@ -29,4 +29,7 @@ public static MarkupString AllowImages(string message) return new MarkupString(result.ToString()); } + + [GeneratedRegex(@"\!?\[([^\]]+)\]\s*\(([^\)]+)\)")] + private static partial Regex FindMarkdownImages(); } diff --git a/src/WebApp/Components/Pages/Cart/CartPage.razor.css b/src/WebApp/Components/Pages/Cart/CartPage.razor.css index 24efa47..3b9c568 100644 --- a/src/WebApp/Components/Pages/Cart/CartPage.razor.css +++ b/src/WebApp/Components/Pages/Cart/CartPage.razor.css @@ -124,7 +124,7 @@ font-size: 1rem; font-weight: 600; border-radius: 0.75rem; - width: 1.5rem; + width: 3.5rem; height: 1.5rem; line-height: 100%; display: inline-flex; diff --git a/src/WebApp/Extensions/Extensions.cs b/src/WebApp/Extensions/Extensions.cs index 78549c1..1aec65a 100644 --- a/src/WebApp/Extensions/Extensions.cs +++ b/src/WebApp/Extensions/Extensions.cs @@ -1,13 +1,21 @@ -using eShop.WebApp; +using System; +using Azure.AI.OpenAI; +using eShop.WebApp; using eShop.EventBusServiceBus; using eShop.WebAppComponents.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Web; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Microsoft.SemanticKernel.TextGeneration; +using eShop.WebApp.Services.OrderStatus.IntegrationEvents; +using eShop.Basket.API.Grpc; public static class Extensions { @@ -73,17 +81,13 @@ public static void AddAuthenticationServices(this IHostApplicationBuilder builde private static void AddAIServices(this IHostApplicationBuilder builder) { var openAIOptions = builder.Configuration.GetSection("AI").Get()?.OpenAI; - if (!string.IsNullOrWhiteSpace(openAIOptions?.ApiKey)) + var deploymentName = openAIOptions?.ChatModel; + + if (!string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("openai")) && !string.IsNullOrWhiteSpace(deploymentName)) { - var kernelBuilder = builder.Services.AddKernel(); - if (!string.IsNullOrWhiteSpace(openAIOptions.Endpoint)) - { - kernelBuilder.AddAzureOpenAIChatCompletion(openAIOptions.ChatModel, openAIOptions.Endpoint, openAIOptions.ApiKey); - } - else - { - kernelBuilder.AddOpenAIChatCompletion(openAIOptions.ChatModel, openAIOptions.ApiKey); - } + builder.Services.AddKernel(); + builder.AddAzureOpenAIClient("openai"); + builder.Services.AddAzureOpenAIChatCompletion(deploymentName); } } diff --git a/src/WebApp/GlobalUsings.cs b/src/WebApp/GlobalUsings.cs index 0a14e3a..d84c2b3 100644 --- a/src/WebApp/GlobalUsings.cs +++ b/src/WebApp/GlobalUsings.cs @@ -1,6 +1,3 @@ global using eShop.WebApp.Components; global using eShop.WebApp.Services; global using eShop.ServiceDefaults; -global using eShop.EventBus.Abstractions; -global using eShop.WebApp.Services.OrderStatus.IntegrationEvents; -global using eShop.Basket.API.Grpc; diff --git a/src/WebApp/Program.cs b/src/WebApp/Program.cs index e0aa9e3..7b64091 100644 --- a/src/WebApp/Program.cs +++ b/src/WebApp/Program.cs @@ -1,4 +1,7 @@ -var builder = WebApplication.CreateBuilder(args); +using eShop.WebApp.Components; +using eShop.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -20,8 +23,6 @@ app.UseAntiforgery(); -app.UseHttpsRedirection(); - app.UseStaticFiles(); app.MapRazorComponents().AddInteractiveServerRenderMode(); diff --git a/src/WebApp/WebApp.csproj b/src/WebApp/WebApp.csproj index 193b08e..22c9f00 100644 --- a/src/WebApp/WebApp.csproj +++ b/src/WebApp/WebApp.csproj @@ -16,7 +16,7 @@
- + diff --git a/src/WebApp/appsettings.json b/src/WebApp/appsettings.json index 6be709f..745f4c7 100644 --- a/src/WebApp/appsettings.json +++ b/src/WebApp/appsettings.json @@ -19,10 +19,10 @@ "EventBus": { "SubscriptionClientName": "Ordering.webapp" }, - //"AI": { - // "OpenAI": { - // "APIKey": "", - // "ChatModel": "", - // } - //} + "SessionCookieLifetimeMinutes": 60, + "AI": { + "OpenAI": { + //"ChatModel": "" + } + } } diff --git a/src/WebAppComponents/Catalog/CatalogItem.cs b/src/WebAppComponents/Catalog/CatalogItem.cs index f7bbc13..ca6af61 100644 --- a/src/WebAppComponents/Catalog/CatalogItem.cs +++ b/src/WebAppComponents/Catalog/CatalogItem.cs @@ -5,7 +5,7 @@ public record CatalogItem( string Name, string Description, decimal Price, - string PictureUri, + string PictureUrl, int CatalogBrandId, CatalogBrand CatalogBrand, int CatalogTypeId, diff --git a/src/WebhookClient/Program.cs b/src/WebhookClient/Program.cs index 55e3847..3bb5e54 100644 --- a/src/WebhookClient/Program.cs +++ b/src/WebhookClient/Program.cs @@ -20,8 +20,6 @@ app.UseAntiforgery(); -app.UseHttpsRedirection(); - app.UseStaticFiles(); app.MapRazorComponents().AddInteractiveServerRenderMode(); diff --git a/src/Webhooks.API/Extensions/Extensions.cs b/src/Webhooks.API/Extensions/Extensions.cs index 5e5cf11..c0434bb 100644 --- a/src/Webhooks.API/Extensions/Extensions.cs +++ b/src/Webhooks.API/Extensions/Extensions.cs @@ -6,10 +6,10 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) { builder.AddDefaultAuthentication(); - builder.AddServiceBusEventBus("EventBus") + builder.AddServiceBusEventBus("eventBus") .AddEventBusSubscriptions(); - builder.AddNpgsqlDbContext("WebHooksDB"); + builder.AddNpgsqlDbContext("webhooksdb"); builder.Services.AddMigration(); diff --git a/src/eShop.AppHost/Extensions.cs b/src/eShop.AppHost/Extensions.cs new file mode 100644 index 0000000..697fd09 --- /dev/null +++ b/src/eShop.AppHost/Extensions.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Aspire.Hosting.Lifecycle; + +namespace eShop.AppHost; + +internal static class Extensions +{ + /// + /// Adds a hook to set the ASPNETCORE_FORWARDEDHEADERS_ENABLED environment variable to true for all projects in the application. + /// + public static IDistributedApplicationBuilder AddForwardedHeaders(this IDistributedApplicationBuilder builder) + { + builder.Services.TryAddLifecycleHook(); + return builder; + } + + private class AddForwardHeadersHook : IDistributedApplicationLifecycleHook + { + public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + foreach (var p in appModel.GetProjectResources()) + { + p.Annotations.Add(new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true"; + })); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/eShop.AppHost/Program.cs b/src/eShop.AppHost/Program.cs index a56b773..a369031 100644 --- a/src/eShop.AppHost/Program.cs +++ b/src/eShop.AppHost/Program.cs @@ -1,20 +1,35 @@ -var builder = DistributedApplication.CreateBuilder(args); + +using Aspire.Hosting; +using eShop.AppHost; +using Microsoft.Extensions.Configuration; -var appInsights = builder.AddApplicationInsights("appInsights"); -var redis = builder.AddRedisContainer("redis"); -var serviceBus = builder.AddAzureServiceBus("EventBus", topicNames: ["eshop_event_bus"]); -var postgres = builder.AddPostgresContainer("postgres") - .WithAnnotation(new ContainerImageAnnotation - { - Image = "ankane/pgvector", - Tag = "latest" - }); +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddForwardedHeaders(); + +var appInsights = builder.ExecutionContext.IsPublishMode + ? builder.AddAzureApplicationInsights("appInsights") + : builder.AddConnectionString("appInsights", "APPLICATIONINSIGHTS_CONNECTION_STRING"); + +var redis = builder.AddRedis("redis"); + +var serviceBus = builder.ExecutionContext.IsPublishMode + ? builder.AddAzureServiceBus("eventBus").AddTopic("eshop_event_bus") + : builder.AddConnectionString("eventBus"); -var catalogDb = postgres.AddDatabase("CatalogDB"); -var orderDb = postgres.AddDatabase("OrderingDB"); -var webhooksDb = postgres.AddDatabase("WebHooksDB"); +var postgres = builder.AddPostgres("postgres") + .WithImage("ankane/pgvector") + .WithImageTag("latest"); + +var catalogDb = postgres.AddDatabase("catalogdb"); +var identityDb = postgres.AddDatabase("identitydb"); +var orderDb = postgres.AddDatabase("orderingdb"); +var webhooksDb = postgres.AddDatabase("webhooksdb"); + +var launchProfileName = ShouldUseHttpForEndpoints() ? "http" : "https"; // Services + var basketApi = builder.AddProject("basket-api") .WithReference(redis) .WithReference(serviceBus) @@ -54,16 +69,68 @@ .WithReference(webHooksApi) .WithReference(appInsights); -var webApp = builder.AddProject("webapp") +var webApp = builder.AddProject("webapp", launchProfileName) + .WithExternalHttpEndpoints() .WithReference(basketApi) .WithReference(catalogApi) .WithReference(orderingApi) .WithReference(serviceBus) - .WithReference(appInsights) - .WithLaunchProfile("https"); + .WithReference(appInsights); + +// set to true if you want to use OpenAI +bool useOpenAI = false; +if (useOpenAI) +{ + const string openAIName = "openai"; + const string textEmbeddingName = "text-embedding-ada-002"; + const string chatModelName = "gpt-35-turbo-16k"; + + // to use an existing OpenAI resource, add the following to the AppHost user secrets: + // "ConnectionStrings": { + // "openai": "Key=" (to use https://api.openai.com/) + // -or- + // "openai": "Endpoint=https://.openai.azure.com/" (to use Azure OpenAI) + // } + IResourceBuilder openAI; + if (builder.Configuration.GetConnectionString(openAIName) is not null) + { + openAI = builder.AddConnectionString(openAIName); + } + else + { + // to use Azure provisioning, add the following to the AppHost user secrets: + // "Azure": { + // "SubscriptionId": "" + // "Location": "" + // } + openAI = builder.AddAzureOpenAI(openAIName) + .AddDeployment(new AzureOpenAIDeployment(chatModelName, "gpt-35-turbo", "0613")) + .AddDeployment(new AzureOpenAIDeployment(textEmbeddingName, "text-embedding-ada-002", "2")); + } + + catalogApi + .WithReference(openAI) + .WithEnvironment("AI__OPENAI__EMBEDDINGNAME", textEmbeddingName); + + webApp + .WithReference(openAI) + .WithEnvironment("AI__OPENAI__CHATMODEL", chatModelName); ; +} // Wire up the callback urls (self referencing) -webApp.WithEnvironment("CallBackUrl", webApp.GetEndpoint("https")); -webhooksClient.WithEnvironment("CallBackUrl", webhooksClient.GetEndpoint("https")); +webApp.WithEnvironment("CallBackUrl", webApp.GetEndpoint(launchProfileName)); +webhooksClient.WithEnvironment("CallBackUrl", webhooksClient.GetEndpoint(launchProfileName)); builder.Build().Run(); + +// For test use only. +// Looks for an environment variable that forces the use of HTTP for all the endpoints. We +// are doing this for ease of running the Playwright tests in CI. +static bool ShouldUseHttpForEndpoints() +{ + const string EnvVarName = "ESHOP_USE_HTTP_ENDPOINTS"; + var envValue = Environment.GetEnvironmentVariable(EnvVarName); + + // Attempt to parse the environment variable value; return true if it's exactly "1". + return int.TryParse(envValue, out int result) && result == 1; +} diff --git a/src/eShop.AppHost/Properties/launchSettings.json b/src/eShop.AppHost/Properties/launchSettings.json index 84439ec..6622446 100644 --- a/src/eShop.AppHost/Properties/launchSettings.json +++ b/src/eShop.AppHost/Properties/launchSettings.json @@ -1,31 +1,29 @@ { + "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "http": { + "https": { "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "", + "applicationUrl": "https://localhost:19888;http://localhost:18848", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_LAUNCH_PROFILE": "http", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16119" - }, - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:18848" + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18076", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:19076" + } }, - "https": { + "http": { "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "", + "applicationUrl": "http://localhost:18848", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_LAUNCH_PROFILE": "https", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18076" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:19888" + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16119", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17119" + } } - }, - "$schema": "http://json.schemastore.org/launchsettings.json" + } } diff --git a/src/eShop.AppHost/appsettings.json b/src/eShop.AppHost/appsettings.json index 839ee10..5739939 100644 --- a/src/eShop.AppHost/appsettings.json +++ b/src/eShop.AppHost/appsettings.json @@ -8,6 +8,7 @@ }, "ConnectionStrings": { "AppInsights": "", - "EventBus": ".servicebus.windows.net" + "EventBus": ".servicebus.windows.net", + //"OpenAi": "Endpoint=xxxx;Key=xxxx" } } diff --git a/src/eShop.AppHost/eShop.AppHost.csproj b/src/eShop.AppHost/eShop.AppHost.csproj index 40717f9..5b32558 100644 --- a/src/eShop.AppHost/eShop.AppHost.csproj +++ b/src/eShop.AppHost/eShop.AppHost.csproj @@ -10,12 +10,16 @@ - + + + + + + - diff --git a/src/eShop.ServiceDefaults/Extensions.cs b/src/eShop.ServiceDefaults/Extensions.cs index 42686dd..c99cf34 100644 --- a/src/eShop.ServiceDefaults/Extensions.cs +++ b/src/eShop.ServiceDefaults/Extensions.cs @@ -27,7 +27,7 @@ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBu http.AddStandardResilienceHandler(); // Turn on service discovery by default - http.UseServiceDiscovery(); + http.AddServiceDiscovery(); }); return builder; @@ -51,27 +51,30 @@ public static IHostApplicationBuilder AddBasicServiceDefaults(this IHostApplicat public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { - builder.Logging.AddOpenTelemetry(o => + builder.Logging.AddOpenTelemetry(logging => { - o.IncludeFormattedMessage = true; - o.IncludeScopes = true; + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; }); builder.Services.AddOpenTelemetry() .WithMetrics(metrics => { - metrics.AddRuntimeInstrumentation() - .AddBuiltInMeters(); + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); }) .WithTracing(tracing => { if (builder.Environment.IsDevelopment()) { + // We want to view all traces in development tracing.SetSampler(new AlwaysOnSampler()); } tracing.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); }); builder.AddOpenTelemetryExporters(); @@ -90,34 +93,14 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } - // Configure alternative exporters - var openTelemetry = builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - // Uncomment the following line to enable the Prometheus endpoint - //metrics.AddPrometheusExporter(); - }); - - if (builder.Configuration.GetConnectionString("AppInsights") is string connectionString) + if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) { - if (!string.IsNullOrEmpty(connectionString)) - { - openTelemetry.UseAzureMonitor(o => - { - o.ConnectionString = connectionString; - }); - } + builder.Services.AddOpenTelemetry().UseAzureMonitor(); } return builder; } - private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => - meterProviderBuilder.AddMeter( - "Microsoft.AspNetCore.Hosting", - "Microsoft.AspNetCore.Server.Kestrel", - "System.Net.Http"); - public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) { builder.Services.AddHealthChecks() @@ -132,14 +115,19 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) // app.MapPrometheusScrapingEndpoint(); - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/liveness", new HealthCheckOptions + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) { - Predicate = r => r.Tags.Contains("live") - }); + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } return app; } diff --git a/src/eShop.ServiceDefaults/OpenApi.Extensions.cs b/src/eShop.ServiceDefaults/OpenApi.Extensions.cs index 2f8914d..fd3eafb 100644 --- a/src/eShop.ServiceDefaults/OpenApi.Extensions.cs +++ b/src/eShop.ServiceDefaults/OpenApi.Extensions.cs @@ -22,37 +22,40 @@ public static IApplicationBuilder UseDefaultOpenApi(this WebApplication app) } app.UseSwagger(); - app.UseSwaggerUI(setup => + if (app.Environment.IsDevelopment()) { - /// { - /// "OpenApi": { - /// "Endpoint: { - /// "Name": - /// }, - /// "Auth": { - /// "ClientId": .., - /// "AppName": .. - /// } - /// } - /// } - - var pathBase = configuration["PATH_BASE"]; - var authSection = openApiSection.GetSection("Auth"); - var endpointSection = openApiSection.GetRequiredSection("Endpoint"); - - var swaggerUrl = endpointSection["Url"] ?? $"{(!string.IsNullOrEmpty(pathBase) ? pathBase : string.Empty)}/swagger/v1/swagger.json"; - - setup.SwaggerEndpoint(swaggerUrl, endpointSection.GetRequiredValue("Name")); - - if (authSection.Exists()) + app.UseSwaggerUI(setup => { - setup.OAuthClientId(authSection.GetRequiredValue("ClientId")); - setup.OAuthAppName(authSection.GetRequiredValue("AppName")); - } - }); + /// { + /// "OpenApi": { + /// "Endpoint: { + /// "Name": + /// }, + /// "Auth": { + /// "ClientId": .., + /// "AppName": .. + /// } + /// } + /// } + + var pathBase = configuration["PATH_BASE"]; + var authSection = openApiSection.GetSection("Auth"); + var endpointSection = openApiSection.GetRequiredSection("Endpoint"); + + var swaggerUrl = endpointSection["Url"] ?? $"{(!string.IsNullOrEmpty(pathBase) ? pathBase : string.Empty)}/swagger/v1/swagger.json"; + + setup.SwaggerEndpoint(swaggerUrl, endpointSection.GetRequiredValue("Name")); + + if (authSection.Exists()) + { + setup.OAuthClientId(authSection.GetRequiredValue("ClientId")); + setup.OAuthAppName(authSection.GetRequiredValue("AppName")); + } + }); - // Add a redirect from the root of the app to the swagger endpoint - app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription(); + // Add a redirect from the root of the app to the swagger endpoint + app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription(); + } return app; } diff --git a/tests/Catalog.FunctionalTests/Catalog.FunctionalTests.csproj b/tests/Catalog.FunctionalTests/Catalog.FunctionalTests.csproj index a726ab9..43880a7 100644 --- a/tests/Catalog.FunctionalTests/Catalog.FunctionalTests.csproj +++ b/tests/Catalog.FunctionalTests/Catalog.FunctionalTests.csproj @@ -8,7 +8,8 @@ - + + @@ -21,7 +22,7 @@ - + diff --git a/tests/Catalog.FunctionalTests/CatalogApiFixture.cs b/tests/Catalog.FunctionalTests/CatalogApiFixture.cs index 3d33451..9b91105 100644 --- a/tests/Catalog.FunctionalTests/CatalogApiFixture.cs +++ b/tests/Catalog.FunctionalTests/CatalogApiFixture.cs @@ -10,18 +10,16 @@ public sealed class CatalogApiFixture : WebApplicationFactory, IAsyncLi { private readonly IHost _app; - public IResourceBuilder Postgres { get; private set; } + public IResourceBuilder Postgres { get; private set; } + private string _postgresConnectionString; public CatalogApiFixture() { var options = new DistributedApplicationOptions { AssemblyName = typeof(CatalogApiFixture).Assembly.FullName, DisableDashboard = true }; var appBuilder = DistributedApplication.CreateBuilder(options); - Postgres = appBuilder.AddPostgresContainer("CatalogDB") - .WithAnnotation(new ContainerImageAnnotation - { - Image = "ankane/pgvector", - Tag = "latest" - }); + Postgres = appBuilder.AddPostgres("CatalogDB") + .WithImage("ankane/pgvector") + .WithImageTag("latest"); _app = appBuilder.Build(); } @@ -37,7 +35,7 @@ protected override IHost CreateHost(IHostBuilder builder) { config.AddInMemoryCollection(new Dictionary { - { $"ConnectionStrings:{Postgres.Resource.Name}", Postgres.Resource.GetConnectionString() }, + { $"ConnectionStrings:{Postgres.Resource.Name}", _postgresConnectionString }, }); }); return base.CreateHost(builder); @@ -60,5 +58,6 @@ protected override IHost CreateHost(IHostBuilder builder) public async Task InitializeAsync() { await _app.StartAsync(); + _postgresConnectionString = await Postgres.Resource.GetConnectionStringAsync(); } } diff --git a/tests/Catalog.FunctionalTests/CatalogApiTests.cs b/tests/Catalog.FunctionalTests/CatalogApiTests.cs index 2836e64..1d0a892 100644 --- a/tests/Catalog.FunctionalTests/CatalogApiTests.cs +++ b/tests/Catalog.FunctionalTests/CatalogApiTests.cs @@ -273,7 +273,6 @@ public async Task AddCatalogItem() Description = "Test catalog description 1", Price = 11000.08m, PictureFileName = null, - PictureUri = null, CatalogTypeId = 8, CatalogType = null, CatalogBrandId = 13, diff --git a/tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj b/tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj index 1ad9c3f..8011149 100644 --- a/tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj +++ b/tests/ClientApp.UnitTests/ClientApp.UnitTests.csproj @@ -12,12 +12,12 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Ordering.FunctionalTests/Ordering.FunctionalTests.csproj b/tests/Ordering.FunctionalTests/Ordering.FunctionalTests.csproj index 2168bd8..bdab7c1 100644 --- a/tests/Ordering.FunctionalTests/Ordering.FunctionalTests.csproj +++ b/tests/Ordering.FunctionalTests/Ordering.FunctionalTests.csproj @@ -8,7 +8,8 @@ - + + @@ -19,11 +20,11 @@ - + - - - + + + \ No newline at end of file diff --git a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs index 0b5d360..3c37709 100644 --- a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs +++ b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs @@ -11,15 +11,17 @@ public sealed class OrderingApiFixture : WebApplicationFactory, IAsyncL { private readonly IHost _app; - public IResourceBuilder Postgres { get; private set; } - public IResourceBuilder IdentityDB { get; private set; } + public IResourceBuilder Postgres { get; private set; } + public IResourceBuilder IdentityDB { get; private set; } + + private string _postgresConnectionString; public OrderingApiFixture() { var options = new DistributedApplicationOptions { AssemblyName = typeof(OrderingApiFixture).Assembly.FullName, DisableDashboard = true }; var appBuilder = DistributedApplication.CreateBuilder(options); - Postgres = appBuilder.AddPostgresContainer("OrderingDB"); - IdentityDB = appBuilder.AddPostgresContainer("IdentityDB"); + Postgres = appBuilder.AddPostgres("OrderingDB"); + IdentityDB = appBuilder.AddPostgres("IdentityDB"); _app = appBuilder.Build(); } @@ -29,7 +31,7 @@ protected override IHost CreateHost(IHostBuilder builder) { config.AddInMemoryCollection(new Dictionary { - { $"ConnectionStrings:{Postgres.Resource.Name}", Postgres.Resource.GetConnectionString() }, + { $"ConnectionStrings:{Postgres.Resource.Name}", _postgresConnectionString } }); }); builder.ConfigureServices(services => @@ -72,6 +74,7 @@ protected override IHost CreateHost(IHostBuilder builder) public async Task InitializeAsync() { await _app.StartAsync(); + _postgresConnectionString = await Postgres.Resource.GetConnectionStringAsync(); } private class AutoAuthorizeStartupFilter : IStartupFilter diff --git a/tests/Ordering.UnitTests/Application/OrdersWebApiTest.cs b/tests/Ordering.UnitTests/Application/OrdersWebApiTest.cs index 3a55acc..acd69d4 100644 --- a/tests/Ordering.UnitTests/Application/OrdersWebApiTest.cs +++ b/tests/Ordering.UnitTests/Application/OrdersWebApiTest.cs @@ -124,8 +124,10 @@ public async Task Get_order_fails() { // Arrange var fakeOrderId = 123; +#pragma warning disable NS5003 _orderQueriesMock.GetOrderAsync(Arg.Any()) .Throws(new KeyNotFoundException()); +#pragma warning restore NS5003 // Act var orderServices = new OrderServices(_mediatorMock, _orderQueriesMock, _identityServiceMock, _loggerMock);