From 0bf3f0b632e555a8113b8cbc9070a1ad37f591bc Mon Sep 17 00:00:00 2001 From: Pat Date: Thu, 6 Nov 2025 22:16:03 +0000 Subject: [PATCH 1/3] add api gateway and docker files --- .dockerignore | 51 ++++++ .github/workflows/build-identity-api.yml | 4 +- GamesApi.sln | 18 ++ README.md | 4 +- .../database/postgresql/flexibleserver.bicep | 154 ------------------ infra/docker-compose.prod.yml | 99 +++++++++++ infra/docker-compose.yml | 68 ++++++++ infra/main.bicep | 0 infra/secrets/postgres_password.txt | 1 + src/Domain/Entities/Achievement.cs | 1 - src/Domain/Entities/Game.cs | 2 - src/Games.Api/DOCKERFILE | 32 ++++ src/Games.Api/Games.Api.csproj | 1 + .../20251024_AddAchievementsAndUserGames.cs | 4 - src/Games.Api/Program.cs | 2 + src/Games.Api/appsettings.json | 9 +- src/Gateway.Api/Dockerfile | 29 ++++ src/Gateway.Api/Gateway.Api.csproj | 14 ++ src/Gateway.Api/Program.cs | 21 +++ src/Gateway.Api/appsettings.Development.json | 41 +++++ src/Gateway.Api/appsettings.json | 42 +++++ src/Identity.Api/Dockerfile | 32 ++++ src/Identity.Api/Identity.Api.csproj | 1 + src/Identity.Api/Program.cs | 2 + src/Identity.Api/appsettings.json | 7 + src/Scriptorium.CQRS/AssemblyMarker.cs | 4 - .../CreateAchievement/CreateAchievement.cs | 34 ---- .../Games/Commands/CreateGame/CreateGame.cs | 41 ----- .../Games/Commands/DeleteGame/DeleteGame.cs | 23 --- .../Games/Commands/UpdateGame/UpdateGame.cs | 39 ----- .../Queries/GetAchievements/AchievementDto.cs | 21 --- .../GetAchievements/GetAchievements.cs | 26 --- .../Games/Queries/GetGames/GameDto.cs | 26 --- .../Games/Queries/GetGames/GamesVm.cs | 8 - .../Games/Queries/GetGames/GetGames.cs | 25 --- .../Commands/CreateUser/CreateUser.cs | 89 ---------- .../Identity/Commands/CreateUser/UserDto.cs | 20 --- .../Identity/Commands/CreateUser/UserVm.cs | 19 --- .../Identity/Commands/LoginUser/LoginUser.cs | 41 ----- src/Scriptorium.CQRS/Scriptorium.CQRS.csproj | 18 -- .../Users/Commands/AddUserGame/AddUserGame.cs | 36 ---- .../Queries/GetUserLibrary/GetUserLibrary.cs | 26 --- .../Queries/GetUserLibrary/UserGameDto.cs | 22 --- .../CreateUserHandlerTests.cs | 7 - 44 files changed, 473 insertions(+), 691 deletions(-) create mode 100644 .dockerignore delete mode 100644 infra/core/database/postgresql/flexibleserver.bicep create mode 100644 infra/docker-compose.prod.yml create mode 100644 infra/docker-compose.yml delete mode 100644 infra/main.bicep create mode 100644 infra/secrets/postgres_password.txt create mode 100644 src/Games.Api/DOCKERFILE create mode 100644 src/Gateway.Api/Dockerfile create mode 100644 src/Gateway.Api/Gateway.Api.csproj create mode 100644 src/Gateway.Api/Program.cs create mode 100644 src/Gateway.Api/appsettings.Development.json create mode 100644 src/Gateway.Api/appsettings.json create mode 100644 src/Identity.Api/Dockerfile delete mode 100644 src/Scriptorium.CQRS/AssemblyMarker.cs delete mode 100644 src/Scriptorium.CQRS/Games/Commands/CreateAchievement/CreateAchievement.cs delete mode 100644 src/Scriptorium.CQRS/Games/Commands/CreateGame/CreateGame.cs delete mode 100644 src/Scriptorium.CQRS/Games/Commands/DeleteGame/DeleteGame.cs delete mode 100644 src/Scriptorium.CQRS/Games/Commands/UpdateGame/UpdateGame.cs delete mode 100644 src/Scriptorium.CQRS/Games/Queries/GetAchievements/AchievementDto.cs delete mode 100644 src/Scriptorium.CQRS/Games/Queries/GetAchievements/GetAchievements.cs delete mode 100644 src/Scriptorium.CQRS/Games/Queries/GetGames/GameDto.cs delete mode 100644 src/Scriptorium.CQRS/Games/Queries/GetGames/GamesVm.cs delete mode 100644 src/Scriptorium.CQRS/Games/Queries/GetGames/GetGames.cs delete mode 100644 src/Scriptorium.CQRS/Identity/Commands/CreateUser/CreateUser.cs delete mode 100644 src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserDto.cs delete mode 100644 src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserVm.cs delete mode 100644 src/Scriptorium.CQRS/Identity/Commands/LoginUser/LoginUser.cs delete mode 100644 src/Scriptorium.CQRS/Scriptorium.CQRS.csproj delete mode 100644 src/Scriptorium.CQRS/Users/Commands/AddUserGame/AddUserGame.cs delete mode 100644 src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/GetUserLibrary.cs delete mode 100644 src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/UserGameDto.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9db0868 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# Git +.git +.gitignore +.gitattributes +*.md + +# VS Code +.vscode + +# Visual Studio +.vs +*.user +*.suo +*.vcproj.user + +# Rider +.idea + +# Build artifacts +**/bin/ +**/obj/ +**/out/ + +# NuGet +packages/ +*.nupkg + +# Test results +TestResults/ +*.trx +*.coverage +*.coveragexml + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Other +*.log +*.tmp +*.temp \ No newline at end of file diff --git a/.github/workflows/build-identity-api.yml b/.github/workflows/build-identity-api.yml index 0c3cde0..8bc9a49 100644 --- a/.github/workflows/build-identity-api.yml +++ b/.github/workflows/build-identity-api.yml @@ -5,15 +5,15 @@ on: branches: [ main, develop ] paths: - 'src/Identity.Api/**' - - 'src/Identity.CQRS/**' - 'src/Domain/**' + - 'src/Application/**' - 'src/Infrastructure/**' pull_request: branches: [ main, develop ] paths: - 'src/Identity.Api/**' - - 'src/Identity.CQRS/**' - 'src/Domain/**' + - 'src/Application/**' - 'src/Infrastructure/**' workflow_dispatch: diff --git a/GamesApi.sln b/GamesApi.sln index d7ad7ee..ae6b7d8 100644 --- a/GamesApi.sln +++ b/GamesApi.sln @@ -40,6 +40,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application.Tests", "Applic EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.Tests", "tests\Application.Tests\Application.Tests.csproj", "{D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gateway.Api", "Gateway.Api", "{391B19C0-5E8B-43CD-8AD2-555126C2C19A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway.Api", "Gateway.Api\Gateway.Api.csproj", "{931FC060-2F32-4307-BB67-D14BE70A7B30}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -134,6 +138,18 @@ Global {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x64.Build.0 = Release|Any CPU {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x86.ActiveCfg = Release|Any CPU {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x86.Build.0 = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x64.ActiveCfg = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x64.Build.0 = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x86.ActiveCfg = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x86.Build.0 = Debug|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|Any CPU.Build.0 = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x64.ActiveCfg = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x64.Build.0 = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x86.ActiveCfg = Release|Any CPU + {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -153,6 +169,8 @@ Global {B3154D0A-A228-4018-8E0A-C4D4CA52E690} = {51202C1F-1C07-42CB-BCC4-D64FD9451A91} {A781A3F5-2A23-4248-8246-3043798B2CCF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3} = {A781A3F5-2A23-4248-8246-3043798B2CCF} + {391B19C0-5E8B-43CD-8AD2-555126C2C19A} = {51202C1F-1C07-42CB-BCC4-D64FD9451A91} + {931FC060-2F32-4307-BB67-D14BE70A7B30} = {391B19C0-5E8B-43CD-8AD2-555126C2C19A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {98394EF5-BDBD-4F73-8B40-6F1B9EA9F24F} diff --git a/README.md b/README.md index 3339c13..19e8626 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,9 @@ dotnet ef database update \ ## TODO -- [ ] Implement **API Gateway** (e.g., Ocelot or YARP) +- [x] Implement **API Gateway** +- [x] Docker compose and Dockermake files - [ ] Add **Load Balancer** for scalability -- [ ] Deploy to **Azure App Services** - [ ] Use **Azure Flexible Database for PostgreSQL** - [ ] Add **Rate Limiting** and **Caching**, possibly Redis diff --git a/infra/core/database/postgresql/flexibleserver.bicep b/infra/core/database/postgresql/flexibleserver.bicep deleted file mode 100644 index 0f4d0a1..0000000 --- a/infra/core/database/postgresql/flexibleserver.bicep +++ /dev/null @@ -1,154 +0,0 @@ -metadata description = 'Creates an Azure Database for PostgreSQL - Flexible Server.' - -param name string -param location string = resourceGroup().location -param tags object = {} - -param sku object -param storage object -param appUserLogin string -@secure() -param appUserLoginPassword string -param administratorLogin string -@secure() -param administratorLoginPassword string -param databaseName string -param allowAzureIPsFirewall bool = false -param allowAllIPsFirewall bool = false -param allowedSingleIPs array = [] -param keyVaultName string -param connectionStringKey string - -// PostgreSQL version -param version string - -param utcNowString string = utcNow('yyyyMMddHHmm') - -// Latest official version 2022-12-01 does not have Bicep types available -resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = { - location: location - tags: tags - name: name - sku: sku - properties: { - version: version - administratorLogin: administratorLogin - administratorLoginPassword: administratorLoginPassword - storage: storage - highAvailability: { - mode: 'Disabled' - } - } - - resource database 'databases' = { - name: databaseName - } - - resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { - name: 'allow-all-IPs' - properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '255.255.255.255' - } - } - - resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { - name: 'allow-all-azure-internal-IPs' - properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '0.0.0.0' - } - } - - resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { - name: 'allow-single-${replace(ip, '.', '')}' - properties: { - startIpAddress: ip - endIpAddress: ip - } - }] -} - -resource psqlDeploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { - name: '${name}-deployment-script' - location: location - kind: 'AzureCLI' - properties: { - azCliVersion: '2.37.0' - retentionInterval: 'PT1H' // Retain the script resource for 1 hour after it ends running - timeout: 'PT5M' // Five minutes - cleanupPreference: 'OnSuccess' - forceUpdateTag: utcNowString - environmentVariables: [ - { - name: 'APPUSERLOGIN' - value: appUserLogin - } - { - name: 'APPUSERPASSWORD' - secureValue: appUserLoginPassword - } - { - name: 'DBNAME' - value: databaseName - } - { - name: 'DBSERVER' - value: name - } - { - name: 'ADMINLOGIN' - value: administratorLogin - } - { - name: 'ADMINLOGINPASSWORD' - secureValue: administratorLoginPassword - } - ] - - scriptContent: ''' -apk add postgresql-client - -cat << EOF > create_user.sql -CREATE ROLE "$APPUSERLOGIN" WITH LOGIN PASSWORD '$APPUSERPASSWORD'; -GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO "$APPUSERLOGIN"; -EOF - -psql "host=$DBSERVER.postgres.database.azure.com user=$ADMINLOGIN dbname=$DBNAME port=5432 password=$ADMINLOGINPASSWORD sslmode=require" < create_user.sql - ''' - } - dependsOn: [ - postgresServer - ] -} - -resource administratorLoginPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { - parent: keyVault - name: 'dbAdminPassword' - properties: { - value: administratorLoginPassword - } -} - -resource appUserLoginPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { - parent: keyVault - name: 'dbAppUserPassword' - properties: { - value: appUserLoginPassword - } -} - -resource sqlAzureConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { - parent: keyVault - name: connectionStringKey - properties: { - value: '${connectionString}; Password=${appUserLoginPassword}' - } -} - -resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { - name: keyVaultName -} - -var connectionString = 'Host=${postgresServer.properties.fullyQualifiedDomainName};Port=5432;Database=${databaseName};Username=${appUserLogin}' -output connectionStringKey string = connectionStringKey \ No newline at end of file diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml new file mode 100644 index 0000000..4db1e00 --- /dev/null +++ b/infra/docker-compose.prod.yml @@ -0,0 +1,99 @@ +# Production docker-compose.yml +services: + postgres: + image: postgres:16 + container_name: postgres-db + restart: unless-stopped + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + networks: + - games-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + + gateway: + build: + context: .. + dockerfile: ./src/Gateway.Api/Dockerfile + container_name: gateway-api + restart: unless-stopped + ports: + - "80:7000" + - "443:7000" # If you add HTTPS + depends_on: + postgres: + condition: service_healthy + games-api: + condition: service_started + identity-api: + condition: service_started + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:7000 + - ReverseProxy__Clusters__games-cluster__Destinations__games-api__Address=http://games-api:7001 + - ReverseProxy__Clusters__identity-cluster__Destinations__identity-api__Address=http://identity-api:7002 + networks: + - games-network + + games-api: + build: + context: .. + dockerfile: ./src/Games.Api/Dockerfile + container_name: games-api + restart: unless-stopped + expose: + - "7001" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:7001 + - ConnectionStrings__GamesDb=Host=postgres;Database=GamesDb;Username=postgres;Password_FILE=/run/secrets/postgres_password + secrets: + - postgres_password + networks: + - games-network + + identity-api: + build: + context: .. + dockerfile: ./src/Identity.Api/Dockerfile + container_name: identity-api + restart: unless-stopped + expose: + - "7002" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:7002 + - ConnectionStrings__Identity=Host=postgres;Database=Identity;Username=postgres;Password_FILE=/run/secrets/postgres_password + secrets: + - postgres_password + networks: + - games-network + +volumes: + postgres_data: + driver: local + +networks: + games-network: + driver: bridge + +secrets: + postgres_password: + file: ./secrets/postgres_password.txt \ No newline at end of file diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..bc58663 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,68 @@ +# docker-compose.yml +services: + postgres: + image: postgres:16 + container_name: postgres-db + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + gateway: + build: + context: .. + dockerfile: ./src/Gateway.Api/Dockerfile + ports: + - "7000:7000" + depends_on: + postgres: + condition: service_healthy + games-api: + condition: service_started + identity-api: + condition: service_started + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:7000 + - ReverseProxy__Clusters__games-cluster__Destinations__games-api__Address=http://games-api:7001 + - ReverseProxy__Clusters__identity-cluster__Destinations__identity-api__Address=http://identity-api:7002 + + games-api: + build: + context: .. + dockerfile: ./src/Games.Api/DOCKERFILE + ports: + - "7001:7001" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:7001 + - ConnectionStrings__GamesDb=Host=postgres;Database=GamesDb;Username=postgres;Password=postgres + + identity-api: + build: + context: .. + dockerfile: ./src/Identity.Api/Dockerfile + ports: + - "7002:7002" + depends_on: + postgres: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:7002 + - ConnectionStrings__Identity=Host=postgres;Database=Identity;Username=postgres;Password=postgres + +volumes: + postgres_data: \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep deleted file mode 100644 index e69de29..0000000 diff --git a/infra/secrets/postgres_password.txt b/infra/secrets/postgres_password.txt new file mode 100644 index 0000000..c216964 --- /dev/null +++ b/infra/secrets/postgres_password.txt @@ -0,0 +1 @@ +dGq4HARyvuJvkBTMwThFZ31U1eKCTM \ No newline at end of file diff --git a/src/Domain/Entities/Achievement.cs b/src/Domain/Entities/Achievement.cs index 531b7b0..cfc80e3 100644 --- a/src/Domain/Entities/Achievement.cs +++ b/src/Domain/Entities/Achievement.cs @@ -13,6 +13,5 @@ public class Achievement : BaseAuditableEntity public int ProgressTotal { get; set; } public int ProgressCurrent { get; set; } - // navigation public Game? Game { get; set; } } diff --git a/src/Domain/Entities/Game.cs b/src/Domain/Entities/Game.cs index 1a2d324..0ec28d2 100644 --- a/src/Domain/Entities/Game.cs +++ b/src/Domain/Entities/Game.cs @@ -9,14 +9,12 @@ public class Game : BaseAuditableEntity public string Title { get; set; } = null!; public DateTime ReleaseDate { get; set; } - // extended metadata public string? Description { get; set; } public string? Genre { get; set; } public string? Developer { get; set; } public string? Publisher { get; set; } public decimal Price { get; set; } - // navigation public ICollection? Achievements { get; set; } = new List(); public ICollection? UserGames { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Games.Api/DOCKERFILE b/src/Games.Api/DOCKERFILE new file mode 100644 index 0000000..12ae3fb --- /dev/null +++ b/src/Games.Api/DOCKERFILE @@ -0,0 +1,32 @@ +# Use the official .NET 9.0 runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 7001 + +# Use the official .NET 9.0 SDK as a build image +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project files and restore dependencies +COPY ["src/Games.Api/Games.Api.csproj", "src/Games.Api/"] +COPY ["src/Application/Application.csproj", "src/Application/"] +COPY ["src/Domain/Domain.csproj", "src/Domain/"] +COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"] + +RUN dotnet restore "src/Games.Api/Games.Api.csproj" + +# Copy the rest of the application code +COPY . . + +WORKDIR "/src/src/Games.Api" +RUN dotnet build "Games.Api.csproj" -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish "Games.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Games.Api.dll"] \ No newline at end of file diff --git a/src/Games.Api/Games.Api.csproj b/src/Games.Api/Games.Api.csproj index 59fc0f3..a2e5fa4 100644 --- a/src/Games.Api/Games.Api.csproj +++ b/src/Games.Api/Games.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Games.Api/Migrations/20251024_AddAchievementsAndUserGames.cs b/src/Games.Api/Migrations/20251024_AddAchievementsAndUserGames.cs index a070a67..f1c73c3 100644 --- a/src/Games.Api/Migrations/20251024_AddAchievementsAndUserGames.cs +++ b/src/Games.Api/Migrations/20251024_AddAchievementsAndUserGames.cs @@ -13,7 +13,6 @@ public partial class AddAchievementsAndUserGames : Migration { protected override void Up(MigrationBuilder migrationBuilder) { - // add new columns to Games table migrationBuilder.AddColumn( name: "Description", table: "Games", @@ -45,7 +44,6 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: 0m); - // create Achievements table migrationBuilder.CreateTable( name: "Achievements", columns: table => new @@ -70,7 +68,6 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); - // create UserGames table migrationBuilder.CreateTable( name: "UserGames", columns: table => new @@ -94,7 +91,6 @@ protected override void Up(MigrationBuilder migrationBuilder) principalTable: "Games", principalColumn: "Id", onDelete: ReferentialAction.Cascade); - // Note: User FK refers to identity schema in a different DbContext; keep as int for now }); migrationBuilder.CreateIndex( diff --git a/src/Games.Api/Program.cs b/src/Games.Api/Program.cs index d140552..91d0749 100644 --- a/src/Games.Api/Program.cs +++ b/src/Games.Api/Program.cs @@ -1,7 +1,9 @@ using Application; using Domain.Common.Data.Extensions; +using HealthChecks.UI.Client; using Infrastructure; using Infrastructure.Data; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); diff --git a/src/Games.Api/appsettings.json b/src/Games.Api/appsettings.json index 26b057d..1f1fc42 100644 --- a/src/Games.Api/appsettings.json +++ b/src/Games.Api/appsettings.json @@ -1,4 +1,11 @@ { + "profiles": { + "Games.Api": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:7001;http://localhost:5001" + } + }, "ConnectionStrings": { "GamesDb": "Server=;Port=;Database=;User Id=;Password=;", "AZURE_SQL_GamesDb": "Data Source=.database.windows.net;Initial Catalog=;Authentication=Active Directory Default;Encrypt=True;" @@ -16,4 +23,4 @@ "Key": "ThisIsNotARealKeyAndNeedsToBeReplacedGoodTryThoughAlsoABitMoreTextToMakeThisLongEnoughForProperValidation" }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/src/Gateway.Api/Dockerfile b/src/Gateway.Api/Dockerfile new file mode 100644 index 0000000..cfc4535 --- /dev/null +++ b/src/Gateway.Api/Dockerfile @@ -0,0 +1,29 @@ +# Use the official .NET 9.0 runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 7000 + +# Use the official .NET 9.0 SDK as a build image +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project files and restore dependencies +COPY ["src/Gateway.Api/Gateway.Api.csproj", "src/Gateway.Api/"] + +RUN dotnet restore "src/Gateway.Api/Gateway.Api.csproj" + +# Copy the rest of the application code +COPY . . + +WORKDIR "/src/src/Gateway.Api" +RUN dotnet build "Gateway.Api.csproj" -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish "Gateway.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Gateway.Api.dll"] \ No newline at end of file diff --git a/src/Gateway.Api/Gateway.Api.csproj b/src/Gateway.Api/Gateway.Api.csproj new file mode 100644 index 0000000..898a9f0 --- /dev/null +++ b/src/Gateway.Api/Gateway.Api.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/src/Gateway.Api/Program.cs b/src/Gateway.Api/Program.cs new file mode 100644 index 0000000..b679ed9 --- /dev/null +++ b/src/Gateway.Api/Program.cs @@ -0,0 +1,21 @@ +using Yarp.ReverseProxy.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseRouting(); + +app.MapReverseProxy(); + +app.Run(); \ No newline at end of file diff --git a/src/Gateway.Api/appsettings.Development.json b/src/Gateway.Api/appsettings.Development.json new file mode 100644 index 0000000..d97de0d --- /dev/null +++ b/src/Gateway.Api/appsettings.Development.json @@ -0,0 +1,41 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Yarp": "Debug" + } + }, + "ReverseProxy": { + "Routes": { + "games-route": { + "ClusterId": "games-cluster", + "Match": { + "Path": "/api/games/{**catch-all}" + } + }, + "identity-route": { + "ClusterId": "identity-cluster", + "Match": { + "Path": "/api/identity/{**catch-all}" + } + } + }, + "Clusters": { + "games-cluster": { + "Destinations": { + "games-api": { + "Address": "http://games-api:7001/" + } + } + }, + "identity-cluster": { + "Destinations": { + "identity-api": { + "Address": "http://identity-api:7002/" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Gateway.Api/appsettings.json b/src/Gateway.Api/appsettings.json new file mode 100644 index 0000000..b4176fe --- /dev/null +++ b/src/Gateway.Api/appsettings.json @@ -0,0 +1,42 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Yarp": "Information" + } + }, + "AllowedHosts": "*", + "ReverseProxy": { + "Routes": { + "games-route": { + "ClusterId": "games-cluster", + "Match": { + "Path": "/api/games/{**catch-all}" + } + }, + "identity-route": { + "ClusterId": "identity-cluster", + "Match": { + "Path": "/api/identity/{**catch-all}" + } + } + }, + "Clusters": { + "games-cluster": { + "Destinations": { + "games-api": { + "Address": "http://localhost:7001/" + } + } + }, + "identity-cluster": { + "Destinations": { + "identity-api": { + "Address": "http://localhost:7002/" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Identity.Api/Dockerfile b/src/Identity.Api/Dockerfile new file mode 100644 index 0000000..886acdb --- /dev/null +++ b/src/Identity.Api/Dockerfile @@ -0,0 +1,32 @@ +# Use the official .NET 9.0 runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 7002 + +# Use the official .NET 9.0 SDK as a build image +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project files and restore dependencies +COPY ["src/Identity.Api/Identity.Api.csproj", "src/Identity.Api/"] +COPY ["src/Application/Application.csproj", "src/Application/"] +COPY ["src/Domain/Domain.csproj", "src/Domain/"] +COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"] + +RUN dotnet restore "src/Identity.Api/Identity.Api.csproj" + +# Copy the rest of the application code +COPY . . + +WORKDIR "/src/src/Identity.Api" +RUN dotnet build "Identity.Api.csproj" -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish "Identity.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Identity.Api.dll"] \ No newline at end of file diff --git a/src/Identity.Api/Identity.Api.csproj b/src/Identity.Api/Identity.Api.csproj index 89a3473..ec168fa 100644 --- a/src/Identity.Api/Identity.Api.csproj +++ b/src/Identity.Api/Identity.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Identity.Api/Program.cs b/src/Identity.Api/Program.cs index eb0ecac..2069071 100644 --- a/src/Identity.Api/Program.cs +++ b/src/Identity.Api/Program.cs @@ -1,7 +1,9 @@ using Application; using Domain.Common.Data.Extensions; +using HealthChecks.UI.Client; using Infrastructure; using Infrastructure.Data; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); diff --git a/src/Identity.Api/appsettings.json b/src/Identity.Api/appsettings.json index 1522de6..3fd8288 100644 --- a/src/Identity.Api/appsettings.json +++ b/src/Identity.Api/appsettings.json @@ -1,4 +1,11 @@ { + "profiles": { + "Identity.Api": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:7002;http://localhost:5002" + } + }, "ConnectionStrings": { "Identity": "Server=;Port=;Database=;User Id=;Password=;", "AZURE_SQL_Identity": "Data Source=.database.windows.net;Initial Catalog=;Authentication=Active Directory Default;Encrypt=True;" diff --git a/src/Scriptorium.CQRS/AssemblyMarker.cs b/src/Scriptorium.CQRS/AssemblyMarker.cs deleted file mode 100644 index 996f2ef..0000000 --- a/src/Scriptorium.CQRS/AssemblyMarker.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Scriptorium.CQRS; - -// marker type used for MediatR assembly registration -public static class AssemblyMarker { } diff --git a/src/Scriptorium.CQRS/Games/Commands/CreateAchievement/CreateAchievement.cs b/src/Scriptorium.CQRS/Games/Commands/CreateAchievement/CreateAchievement.cs deleted file mode 100644 index 0c8fd8e..0000000 --- a/src/Scriptorium.CQRS/Games/Commands/CreateAchievement/CreateAchievement.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Domain.Common.Interfaces; -using Domain.Entities; -using MediatR; - -namespace Scriptorium.CQRS.Games.Commands.CreateAchievement; - -public record CreateAchievementCommand : IRequest -{ - public int GameId { get; set; } - public string Name { get; set; } = null!; - public string? Description { get; set; } - public int Points { get; set; } -} - -public class CreateAchievementCommandHandler(IGamesDbContext context) : IRequestHandler -{ - public async Task Handle(CreateAchievementCommand request, CancellationToken cancellationToken) - { - var achievement = new Achievement - { - GameId = request.GameId, - Name = request.Name, - Description = request.Description, - Points = request.Points, - CreatedOn = DateTime.UtcNow, - UpdatedOn = DateTime.UtcNow - }; - - context.Achievements.Add(achievement); - await context.SaveChangesAsync(cancellationToken); - - return achievement.Id; - } -} diff --git a/src/Scriptorium.CQRS/Games/Commands/CreateGame/CreateGame.cs b/src/Scriptorium.CQRS/Games/Commands/CreateGame/CreateGame.cs deleted file mode 100644 index b716e95..0000000 --- a/src/Scriptorium.CQRS/Games/Commands/CreateGame/CreateGame.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Domain.Common.Interfaces; -using Domain.Entities; -using MediatR; - -namespace Scriptorium.CQRS.Games.Commands.CreateGame; - -public record CreateGameCommand : IRequest -{ - public string Title { get; set; } = null!; - public DateTime ReleaseDate { get; set; } - public string? Description { get; set; } - public string? Genre { get; set; } - public string? Developer { get; set; } - public string? Publisher { get; set; } - public decimal? Price { get; set; } -} - -public class CreateGameCommandHandler(IGamesDbContext context) : IRequestHandler -{ - public async Task Handle(CreateGameCommand request, CancellationToken cancellationToken) - { - var entity = new Game - { - Title = request.Title, - ReleaseDate = request.ReleaseDate, - Description = request.Description, - Genre = request.Genre, - Developer = request.Developer, - Publisher = request.Publisher, - Price = request.Price ?? 0m, - CreatedOn = DateTime.UtcNow, - UpdatedOn = DateTime.UtcNow - }; - - context.Games.Add(entity); - - await context.SaveChangesAsync(cancellationToken); - - return entity.Id; - } -} diff --git a/src/Scriptorium.CQRS/Games/Commands/DeleteGame/DeleteGame.cs b/src/Scriptorium.CQRS/Games/Commands/DeleteGame/DeleteGame.cs deleted file mode 100644 index 226c73a..0000000 --- a/src/Scriptorium.CQRS/Games/Commands/DeleteGame/DeleteGame.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Ardalis.GuardClauses; -using Domain.Common.Interfaces; -using MediatR; - -namespace Scriptorium.CQRS.Games.Commands.DeleteGame; - -public record DeleteGameCommand(int Id) : IRequest; - -public class DeleteGameCommandHandler(IGamesDbContext context) : IRequestHandler -{ - public async Task Handle(DeleteGameCommand request, CancellationToken cancellationToken) - { - var entity = await context.Games - .FindAsync([request.Id], cancellationToken); - - Guard.Against.NotFound(request.Id, entity); - - context.Games.Remove(entity); - - await context.SaveChangesAsync(cancellationToken); - } - -} diff --git a/src/Scriptorium.CQRS/Games/Commands/UpdateGame/UpdateGame.cs b/src/Scriptorium.CQRS/Games/Commands/UpdateGame/UpdateGame.cs deleted file mode 100644 index 3151ddb..0000000 --- a/src/Scriptorium.CQRS/Games/Commands/UpdateGame/UpdateGame.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Ardalis.GuardClauses; -using Domain.Common.Interfaces; -using MediatR; - -namespace Scriptorium.CQRS.Games.Commands.UpdateGame; - -public record UpdateGameCommand(int Id) : IRequest -{ - public string Title { get; set; } = null!; - public DateTime ReleaseDate { get; set; } - public string? Description { get; set; } - public string? Genre { get; set; } - public string? Developer { get; set; } - public string? Publisher { get; set; } - public decimal? Price { get; set; } - -} - -public class UpdateGameCommandHandler(IGamesDbContext context) : IRequestHandler -{ - public async Task Handle(UpdateGameCommand request, CancellationToken cancellationToken) - { - var entity = await context.Games - .FindAsync([request.Id], cancellationToken); - - Guard.Against.NotFound(request.Id, entity); - - entity.Title = request.Title; - entity.ReleaseDate = request.ReleaseDate; - entity.Description = request.Description; - entity.Genre = request.Genre; - entity.Developer = request.Developer; - entity.Publisher = request.Publisher; - entity.Price = request.Price ?? entity.Price; - entity.UpdatedOn = DateTime.UtcNow; - - await context.SaveChangesAsync(cancellationToken); - } -} diff --git a/src/Scriptorium.CQRS/Games/Queries/GetAchievements/AchievementDto.cs b/src/Scriptorium.CQRS/Games/Queries/GetAchievements/AchievementDto.cs deleted file mode 100644 index 987cc95..0000000 --- a/src/Scriptorium.CQRS/Games/Queries/GetAchievements/AchievementDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AutoMapper; -using Domain.Entities; - -namespace Scriptorium.CQRS.Games.Queries.GetAchievements; - -public class AchievementDto -{ - public int Id { get; init; } - public int GameId { get; init; } - public string Name { get; init; } = null!; - public string? Description { get; init; } - public int Points { get; init; } - - public class Mapping : Profile - { - public Mapping() - { - CreateMap(); - } - } -} diff --git a/src/Scriptorium.CQRS/Games/Queries/GetAchievements/GetAchievements.cs b/src/Scriptorium.CQRS/Games/Queries/GetAchievements/GetAchievements.cs deleted file mode 100644 index 10756a8..0000000 --- a/src/Scriptorium.CQRS/Games/Queries/GetAchievements/GetAchievements.cs +++ /dev/null @@ -1,26 +0,0 @@ -using AutoMapper.QueryableExtensions; -using Domain.Common.Interfaces; -using MediatR; -using Microsoft.EntityFrameworkCore; - -namespace Scriptorium.CQRS.Games.Queries.GetAchievements; - -public record GetAchievementsQuery(int GameId) : IRequest; - -public class GetAchievementsQueryHandler(IGamesDbContext context, AutoMapper.IMapper mapper) : IRequestHandler -{ - public async Task Handle(GetAchievementsQuery request, CancellationToken cancellationToken) - { - var dtos = await context.Achievements - .Where(a => a.GameId == request.GameId) - .ProjectTo(mapper.ConfigurationProvider) - .ToListAsync(cancellationToken); - - return new AchievementsVm { Achievements = dtos }; - } -} - -public class AchievementsVm -{ - public List Achievements { get; set; } = new(); -} diff --git a/src/Scriptorium.CQRS/Games/Queries/GetGames/GameDto.cs b/src/Scriptorium.CQRS/Games/Queries/GetGames/GameDto.cs deleted file mode 100644 index 7570d79..0000000 --- a/src/Scriptorium.CQRS/Games/Queries/GetGames/GameDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -using AutoMapper; -using Domain.Entities; - -namespace Scriptorium.CQRS.Games.Queries.GetGames; - -public class GameDto -{ - public int Id { get; init; } - public string Title { get; init; } = null!; - public DateTime ReleaseDate { get; init; } - public string? Description { get; init; } - public string? Genre { get; init; } - public string? Developer { get; init; } - public string? Publisher { get; init; } - public decimal Price { get; init; } - public DateTime CreatedOn { get; init; } - public DateTime UpdatedOn { get; init; } - - public class Mapping : Profile - { - public Mapping() - { - CreateMap(); - } - } -} diff --git a/src/Scriptorium.CQRS/Games/Queries/GetGames/GamesVm.cs b/src/Scriptorium.CQRS/Games/Queries/GetGames/GamesVm.cs deleted file mode 100644 index 021e263..0000000 --- a/src/Scriptorium.CQRS/Games/Queries/GetGames/GamesVm.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace Scriptorium.CQRS.Games.Queries.GetGames; - -public class GamesVm -{ - public List Games { get; set; } = new(); -} diff --git a/src/Scriptorium.CQRS/Games/Queries/GetGames/GetGames.cs b/src/Scriptorium.CQRS/Games/Queries/GetGames/GetGames.cs deleted file mode 100644 index 9a9414f..0000000 --- a/src/Scriptorium.CQRS/Games/Queries/GetGames/GetGames.cs +++ /dev/null @@ -1,25 +0,0 @@ -using AutoMapper; -using AutoMapper.QueryableExtensions; -using Domain.Common.Interfaces; -using MediatR; -using Microsoft.EntityFrameworkCore; - -namespace Scriptorium.CQRS.Games.Queries.GetGames; - -public record GetGamesQuery : IRequest; - -public class GetGamesQueryHandler(IGamesDbContext context, IMapper mapper) - : IRequestHandler -{ - public async Task Handle(GetGamesQuery request, CancellationToken cancellationToken) - { - var gamesDtos = await context.Games - .ProjectTo(mapper.ConfigurationProvider) - .ToListAsync(cancellationToken: cancellationToken); - - return new GamesVm() - { - Games = gamesDtos - }; - } -} diff --git a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/CreateUser.cs b/src/Scriptorium.CQRS/Identity/Commands/CreateUser/CreateUser.cs deleted file mode 100644 index 7f88f14..0000000 --- a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/CreateUser.cs +++ /dev/null @@ -1,89 +0,0 @@ -using AutoMapper; -using Domain.Common.Interfaces; -using Domain.Entities; -using Domain.Identity.PasswordHashers; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Npgsql; -using FluentEmail.Core; - -namespace Scriptorium.CQRS.Identity.Commands.CreateUser; - -public record CreateUserCommand : IRequest -{ - public string Email { get; set; } = null!; - public string FirstName { get; set; } = null!; - public string LastName { get; set; } = null!; - public string Password { get; set; } = null!; -} - -public class CreateUserCommandHandler( - IIdentityDbContext context, - PasswordHasher passwordHasher, - IFluentEmail fluentEmail, - Domain.Common.Interfaces.IEmailVerificationLinkFactory emailVerificationLinkFactory, - IMapper mapper) : IRequestHandler -{ - public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken) - { - if (await context.Users.AnyAsync(u => u.Email == request.Email, cancellationToken: cancellationToken)) - { - throw new Exception("The email is already in use"); - } - - DateTime utcNow = DateTime.UtcNow; - - var user = new User - { - Email = request.Email, - FirstName = request.FirstName, - LastName = request.LastName, - PasswordHash = passwordHasher.HashPassword(request.Password), - CreatedOn = utcNow, - UpdatedOn = utcNow, - EmailVerified = false // will be set after verification - }; - - context.Users.Add(user); - await context.SaveChangesAsync(cancellationToken: cancellationToken); - - // create verification token and send email - await VerifyEmailAsync(user, cancellationToken); - - var userDto = mapper.Map(user); - - return mapper.Map(userDto); - } - - private async Task VerifyEmailAsync(User user, CancellationToken cancellationToken) - { - DateTime utcNow = DateTime.UtcNow; - var verificationToken = new EmailVerificationToken - { - UserId = user.Id, - CreatedOn = utcNow, - UpdatedOn = utcNow, - ExpiresOn = utcNow.AddHours(1), - }; - - context.EmailVerificationTokens.Add(verificationToken); - - try - { - await context.SaveChangesAsync(cancellationToken: cancellationToken); - } - catch (DbUpdateException e) - when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) - { - throw new Exception("The email is already in use", e); - } - - string verificationLink = emailVerificationLinkFactory.Create(verificationToken); - - await fluentEmail - .To(user.Email) - .Subject("Email verification for Scriptorium API") - .Body($"To verify your email address click here", isHtml: true) - .SendAsync(); - } -} diff --git a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserDto.cs b/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserDto.cs deleted file mode 100644 index df954d9..0000000 --- a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserDto.cs +++ /dev/null @@ -1,20 +0,0 @@ -using AutoMapper; -using Domain.Entities; - -namespace Scriptorium.CQRS.Identity.Commands.CreateUser; - -public class UserDto -{ - public int Id { get; init; } - public string Email { get; init; } = null!; - public string FirstName { get; init; } = null!; - public string LastName { get; init; } = null!; - - public class Mapping : Profile - { - public Mapping() - { - CreateMap(); - } - } -} diff --git a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserVm.cs b/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserVm.cs deleted file mode 100644 index 465968a..0000000 --- a/src/Scriptorium.CQRS/Identity/Commands/CreateUser/UserVm.cs +++ /dev/null @@ -1,19 +0,0 @@ -using AutoMapper; - -namespace Scriptorium.CQRS.Identity.Commands.CreateUser; - -public class UserVm -{ - public int Id { get; init; } - public string Email { get; init; } = null!; - public string FirstName { get; init; } = null!; - public string LastName { get; init; } = null!; - - public class Mapping : Profile - { - public Mapping() - { - CreateMap(); - } - } -} diff --git a/src/Scriptorium.CQRS/Identity/Commands/LoginUser/LoginUser.cs b/src/Scriptorium.CQRS/Identity/Commands/LoginUser/LoginUser.cs deleted file mode 100644 index ba3d919..0000000 --- a/src/Scriptorium.CQRS/Identity/Commands/LoginUser/LoginUser.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Ardalis.GuardClauses; -using Domain.Common.Interfaces; -using Domain.Entities; -using Domain.Identity.IdentityProviders; -using Domain.Identity.PasswordHashers; -using Infrastructure.Data.Extensions; -using MediatR; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -namespace Scriptorium.CQRS.Identity.Commands.LoginUser; - -public record LoginUserCommand : IRequest -{ - [Required] - [EmailAddress] - [DefaultValue("admin@example.com")] - public string Email { get; set; } = null!; - - [Required] - [DefaultValue("AdminPassword123£")] - public string Password { get; set; } = null!; -} - -public class LoginUserCommandHandler(IIdentityDbContext context, PasswordHasher passwordHasher, TokenProvider tokenProvider) : IRequestHandler -{ - public async Task Handle(LoginUserCommand request, CancellationToken cancellationToken) - { - User? user = await context.Users.GetByEmailAsync(request.Email, cancellationToken); - - Guard.Against.NotFound(request.Email, user); - - bool verified = passwordHasher.Verify(request.Password, user.PasswordHash); - - Guard.Against.NotFound(false, verified); - - string token = tokenProvider.Create(user); - - return token; - } -} diff --git a/src/Scriptorium.CQRS/Scriptorium.CQRS.csproj b/src/Scriptorium.CQRS/Scriptorium.CQRS.csproj deleted file mode 100644 index f8b5463..0000000 --- a/src/Scriptorium.CQRS/Scriptorium.CQRS.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - diff --git a/src/Scriptorium.CQRS/Users/Commands/AddUserGame/AddUserGame.cs b/src/Scriptorium.CQRS/Users/Commands/AddUserGame/AddUserGame.cs deleted file mode 100644 index 59d71b8..0000000 --- a/src/Scriptorium.CQRS/Users/Commands/AddUserGame/AddUserGame.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Domain.Common.Interfaces; -using Domain.Entities; -using MediatR; - -namespace Scriptorium.CQRS.Users.Commands.AddUserGame; - -public record AddUserGameCommand : IRequest -{ - public int UserId { get; set; } - public int GameId { get; set; } - public DateTime? OwnedAt { get; set; } - public double PlayTimeHours { get; set; } - public bool IsInstalled { get; set; } -} - -public class AddUserGameCommandHandler(IGamesDbContext context) : IRequestHandler -{ - public async Task Handle(AddUserGameCommand request, CancellationToken cancellationToken) - { - var entity = new UserGame - { - UserId = request.UserId, - GameId = request.GameId, - OwnedAt = request.OwnedAt ?? DateTime.UtcNow, - PlayTimeHours = request.PlayTimeHours, - IsInstalled = request.IsInstalled, - CreatedOn = DateTime.UtcNow, - UpdatedOn = DateTime.UtcNow - }; - - context.UserGames.Add(entity); - await context.SaveChangesAsync(cancellationToken); - - return entity.Id; - } -} diff --git a/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/GetUserLibrary.cs b/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/GetUserLibrary.cs deleted file mode 100644 index db9092a..0000000 --- a/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/GetUserLibrary.cs +++ /dev/null @@ -1,26 +0,0 @@ -using AutoMapper.QueryableExtensions; -using Domain.Common.Interfaces; -using MediatR; -using Microsoft.EntityFrameworkCore; - -namespace Scriptorium.CQRS.Users.Queries.GetUserLibrary; - -public record GetUserLibraryQuery(int UserId) : IRequest; - -public class GetUserLibraryQueryHandler(IGamesDbContext context, AutoMapper.IMapper mapper) : IRequestHandler -{ - public async Task Handle(GetUserLibraryQuery request, CancellationToken cancellationToken) - { - var items = await context.UserGames - .Where(ug => ug.UserId == request.UserId) - .ProjectTo(mapper.ConfigurationProvider) - .ToListAsync(cancellationToken); - - return new UserLibraryVm { Items = items }; - } -} - -public class UserLibraryVm -{ - public List Items { get; set; } = new(); -} diff --git a/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/UserGameDto.cs b/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/UserGameDto.cs deleted file mode 100644 index 29c5fd5..0000000 --- a/src/Scriptorium.CQRS/Users/Queries/GetUserLibrary/UserGameDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AutoMapper; -using Domain.Entities; - -namespace Scriptorium.CQRS.Users.Queries.GetUserLibrary; - -public class UserGameDto -{ - public int Id { get; init; } - public int UserId { get; init; } - public int GameId { get; init; } - public DateTime? OwnedAt { get; init; } - public double PlayTimeHours { get; init; } - public bool IsInstalled { get; init; } - - public class Mapping : Profile - { - public Mapping() - { - CreateMap(); - } - } -} diff --git a/tests/Application.Tests/CreateUserHandlerTests.cs b/tests/Application.Tests/CreateUserHandlerTests.cs index 1210964..115749a 100644 --- a/tests/Application.Tests/CreateUserHandlerTests.cs +++ b/tests/Application.Tests/CreateUserHandlerTests.cs @@ -28,20 +28,17 @@ public class CreateUserCommandHandlerTests public CreateUserCommandHandlerTests() { - // Setup In-Memory DbContext var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _context = new IdentityDbContext(options); - // Mocks _passwordHasher = Substitute.For>(); _fluentEmail = Substitute.For(); _linkFactory = Substitute.For(); _loggerFactory = Substitute.For(); - // AutoMapper Configuration var config = new MapperConfiguration(cfg => { cfg.CreateMap(); @@ -139,13 +136,10 @@ public async Task Handle_UniqueViolationOnToken_ThrowsEmailInUseException() _passwordHasher.HashPassword(default, command.Password).ReturnsForAnyArgs("hash123"); - // First save succeeds (user created) - // But second SaveChanges (for token) throws UniqueViolation var dbUpdateEx = new DbUpdateException( "Unique violation", new PostgresException("duplicate key value", "23505", "XX000", "duplicate key value violates unique constraint")); - // Simulate: first SaveChanges (user) OK, second (token) fails var callCount = 0; @@ -179,7 +173,6 @@ public async Task Handle_CancellationRequested_PropagatesCancellation() await act.Should().ThrowAsync(); } - // Optional: Test mapping chain [Fact] public void Mapper_UserToUserVm_MapsCorrectly() { From 943dcf8c293d30453593b062e938f191ad8d1584 Mon Sep 17 00:00:00 2001 From: Pat Date: Thu, 6 Nov 2025 22:20:12 +0000 Subject: [PATCH 2/3] move gateway to the correct folder --- GamesApi.sln | 28 +++++++++---------- .../Properties/launchSettings.json | 12 ++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 src/Gateway.Api/Properties/launchSettings.json diff --git a/GamesApi.sln b/GamesApi.sln index ae6b7d8..874961a 100644 --- a/GamesApi.sln +++ b/GamesApi.sln @@ -42,7 +42,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.Tests", "tests\ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gateway.Api", "Gateway.Api", "{391B19C0-5E8B-43CD-8AD2-555126C2C19A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway.Api", "Gateway.Api\Gateway.Api.csproj", "{931FC060-2F32-4307-BB67-D14BE70A7B30}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway.Api", "src\Gateway.Api\Gateway.Api.csproj", "{ED6B6783-B070-ED0E-3429-1846E07E78C8}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -138,18 +138,18 @@ Global {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x64.Build.0 = Release|Any CPU {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x86.ActiveCfg = Release|Any CPU {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3}.Release|x86.Build.0 = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|Any CPU.Build.0 = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x64.ActiveCfg = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x64.Build.0 = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x86.ActiveCfg = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Debug|x86.Build.0 = Debug|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|Any CPU.ActiveCfg = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|Any CPU.Build.0 = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x64.ActiveCfg = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x64.Build.0 = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x86.ActiveCfg = Release|Any CPU - {931FC060-2F32-4307-BB67-D14BE70A7B30}.Release|x86.Build.0 = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|x64.Build.0 = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Debug|x86.Build.0 = Debug|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|Any CPU.Build.0 = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|x64.ActiveCfg = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|x64.Build.0 = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|x86.ActiveCfg = Release|Any CPU + {ED6B6783-B070-ED0E-3429-1846E07E78C8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -170,7 +170,7 @@ Global {A781A3F5-2A23-4248-8246-3043798B2CCF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {D36F9506-00AD-3D0B-2CDA-FF6675FE1CF3} = {A781A3F5-2A23-4248-8246-3043798B2CCF} {391B19C0-5E8B-43CD-8AD2-555126C2C19A} = {51202C1F-1C07-42CB-BCC4-D64FD9451A91} - {931FC060-2F32-4307-BB67-D14BE70A7B30} = {391B19C0-5E8B-43CD-8AD2-555126C2C19A} + {ED6B6783-B070-ED0E-3429-1846E07E78C8} = {391B19C0-5E8B-43CD-8AD2-555126C2C19A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {98394EF5-BDBD-4F73-8B40-6F1B9EA9F24F} diff --git a/src/Gateway.Api/Properties/launchSettings.json b/src/Gateway.Api/Properties/launchSettings.json new file mode 100644 index 0000000..c303d33 --- /dev/null +++ b/src/Gateway.Api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Gateway.Api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64357;http://localhost:64358" + } + } +} \ No newline at end of file From 63b69fd623b647be186dd33a77040426a37df61c Mon Sep 17 00:00:00 2001 From: Pat Date: Fri, 7 Nov 2025 14:12:37 +0000 Subject: [PATCH 3/3] fluent validation; global exception handler --- README.md | 14 +++- src/Application/Application.csproj | 1 + .../Common/Behaviours/ValidationBehaviour.cs | 31 +++++++++ .../Exceptions/EmailAlreadyInUseException.cs | 20 ++++++ .../IServiceCollectionExtensions.cs | 11 ++- .../Commands/CreateUser/CreateUser.cs | 8 +-- .../CreateUser/CreateUserCommandValidator.cs | 37 ++++++++++ .../LoginUser/LoginUserCommandValidator.cs | 19 ++++++ src/Games.Api/Games.Api.csproj | 1 + src/Games.Api/Program.cs | 39 ++++++++++- src/Games.Api/Properties/launchSettings.json | 4 +- src/Games.Api/appsettings.Development.json | 2 +- src/Games.Api/appsettings.json | 2 +- src/Gateway.Api/appsettings.json | 4 +- src/Identity.Api/Identity.Api.csproj | 1 + src/Identity.Api/Program.cs | 37 ++++++++++ .../Properties/launchSettings.json | 4 +- src/Identity.Api/appsettings.Development.json | 2 +- src/Identity.Api/appsettings.json | 2 +- src/Infrastructure/Infrastructure.csproj | 1 + .../GlobalExceptionHandlingMiddleware.cs | 68 +++++++++++++++++++ .../Middleware/MiddlewareExtensions.cs | 11 +++ .../CreateUserHandlerTests.cs | 18 ++--- 23 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 src/Application/Common/Behaviours/ValidationBehaviour.cs create mode 100644 src/Application/Common/Exceptions/EmailAlreadyInUseException.cs create mode 100644 src/Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs create mode 100644 src/Application/Identity/Commands/LoginUser/LoginUserCommandValidator.cs create mode 100644 src/Infrastructure/Middleware/GlobalExceptionHandlingMiddleware.cs create mode 100644 src/Infrastructure/Middleware/MiddlewareExtensions.cs diff --git a/README.md b/README.md index 19e8626..bb0ac08 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,17 @@ The solution is structured into two APIs: - `GamesDb` - `IdentityDb` -2. **Run Migrations** (see below) - -3. **Seed Data & Authentication** +2. **JWT Key Configuration** + For production, set the JWT key using environment variables or Azure Key Vault: + ```bash + # Environment variable + export JwtSettings__Key="your-super-secure-jwt-key-at-least-256-bits-long" + + # Or use appsettings.Production.json + ``` +3. **Run Migrations** (see below) + +4. **Seed Data & Authentication** Run in **Debug mode** to auto-seed initial data. Use the `/users/login` endpoint to get a **JWT token**, then: - In **Swagger**: Click **Authorize** → paste `Bearer ` diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 9d4f7c5..5fd4d3c 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Application/Common/Behaviours/ValidationBehaviour.cs b/src/Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..3f64a16 --- /dev/null +++ b/src/Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using MediatR; + +namespace Application.Common.Behaviours; + +public class ValidationBehaviour(IEnumerable> validators) : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators = validators; + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.TryGetNonEnumeratedCount(out var count) && count > 0) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Count != 0) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + } + + return await next(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Common/Exceptions/EmailAlreadyInUseException.cs b/src/Application/Common/Exceptions/EmailAlreadyInUseException.cs new file mode 100644 index 0000000..64a51ee --- /dev/null +++ b/src/Application/Common/Exceptions/EmailAlreadyInUseException.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Application.Common.Exceptions; + +[Serializable] +public class EmailAlreadyInUseException : Exception +{ + public EmailAlreadyInUseException() : base("The email is already in use.") + { + } + + public EmailAlreadyInUseException(string email) : base($"The email '{email}' is already in use.") + { + } + + public EmailAlreadyInUseException(string email, Exception innerException) : base($"The email '{email}' is already in use.", innerException) + { + } + +} \ No newline at end of file diff --git a/src/Application/Extensions/IServiceCollectionExtensions.cs b/src/Application/Extensions/IServiceCollectionExtensions.cs index 45f63bf..da67132 100644 --- a/src/Application/Extensions/IServiceCollectionExtensions.cs +++ b/src/Application/Extensions/IServiceCollectionExtensions.cs @@ -23,6 +23,9 @@ using Application.Games.Commands.UpdateAchievement; using Application.Games.Commands.ProgressAchievement; using Application.Identity.Queries.GetUsers; +using FluentValidation; +using Application.Common.Behaviours; +using System.Reflection; namespace Application.Extensions { @@ -34,6 +37,12 @@ internal static void AddGamesServices(this IServiceCollection services) services.AddAutoMapper(cfg => { }, typeof(GameDto.Mapping), typeof(AchievementDto.Mapping)); + + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + + services.AddScoped, CreateUserCommandValidator>(); + services.AddScoped, LoginUserCommandValidator>(); } internal static void AddIdentityServices(this IServiceCollection services) @@ -43,7 +52,7 @@ internal static void AddIdentityServices(this IServiceCollection services) services.AddSingleton(); services.AddScoped, PasswordHasher>(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHttpContextAccessor(); } diff --git a/src/Application/Identity/Commands/CreateUser/CreateUser.cs b/src/Application/Identity/Commands/CreateUser/CreateUser.cs index c3b53c5..9c8738d 100644 --- a/src/Application/Identity/Commands/CreateUser/CreateUser.cs +++ b/src/Application/Identity/Commands/CreateUser/CreateUser.cs @@ -1,13 +1,11 @@ -using AutoMapper; using Domain.Common.Interfaces; using Domain.Entities; -using Domain.Identity.PasswordHashers; using MediatR; using Microsoft.EntityFrameworkCore; using Npgsql; using FluentEmail.Core; using Microsoft.AspNetCore.Identity; -using Application.Identity.Queries.GetUsers; +using Application.Common.Exceptions; namespace Application.Identity.Commands.CreateUser; @@ -29,7 +27,7 @@ public async Task Handle(CreateUserCommand request, CancellationToken cance { if (await context.Users.AnyAsync(u => u.Email == request.Email, cancellationToken: cancellationToken)) { - throw new Exception("The email is already in use"); + throw new EmailAlreadyInUseException(request.Email); } DateTime utcNow = DateTime.UtcNow; @@ -76,7 +74,7 @@ private async Task VerifyEmailAsync(User user, CancellationToken cancellationTok catch (DbUpdateException e) when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) { - throw new Exception("The email is already in use", e); + throw new EmailAlreadyInUseException(user.Email, e); } string verificationLink = emailVerificationLinkFactory.Create(verificationToken); diff --git a/src/Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs b/src/Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs new file mode 100644 index 0000000..0e9d572 --- /dev/null +++ b/src/Application/Identity/Commands/CreateUser/CreateUserCommandValidator.cs @@ -0,0 +1,37 @@ +using FluentValidation; + +namespace Application.Identity.Commands.CreateUser; + +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Email must be a valid email address.") + .MaximumLength(256) + .WithMessage("Email must not exceed 256 characters."); + + RuleFor(x => x.FirstName) + .NotEmpty() + .WithMessage("First name is required.") + .MaximumLength(100) + .WithMessage("First name must not exceed 100 characters."); + + RuleFor(x => x.LastName) + .NotEmpty() + .WithMessage("Last name is required.") + .MaximumLength(100) + .WithMessage("Last name must not exceed 100 characters."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required.") + .MinimumLength(8) + .WithMessage("Password must be at least 8 characters long.") + .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]") + .WithMessage("Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character."); + } +} \ No newline at end of file diff --git a/src/Application/Identity/Commands/LoginUser/LoginUserCommandValidator.cs b/src/Application/Identity/Commands/LoginUser/LoginUserCommandValidator.cs new file mode 100644 index 0000000..31093f5 --- /dev/null +++ b/src/Application/Identity/Commands/LoginUser/LoginUserCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace Application.Identity.Commands.LoginUser; + +public class LoginUserCommandValidator : AbstractValidator +{ + public LoginUserCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required.") + .EmailAddress() + .WithMessage("Email must be a valid email address."); + + RuleFor(x => x.Password) + .NotEmpty() + .WithMessage("Password is required."); + } +} \ No newline at end of file diff --git a/src/Games.Api/Games.Api.csproj b/src/Games.Api/Games.Api.csproj index a2e5fa4..1b8ee80 100644 --- a/src/Games.Api/Games.Api.csproj +++ b/src/Games.Api/Games.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Games.Api/Program.cs b/src/Games.Api/Program.cs index 91d0749..517e70b 100644 --- a/src/Games.Api/Program.cs +++ b/src/Games.Api/Program.cs @@ -3,6 +3,7 @@ using HealthChecks.UI.Client; using Infrastructure; using Infrastructure.Data; +using Infrastructure.Middleware; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Scalar.AspNetCore; @@ -12,14 +13,40 @@ builder.Services.AddOpenApi(); builder.Services.AddOpenApiDocument(configuration, "Games"); +builder.Services.AddCors(options => +{ + // usual frontend dev servers + options.AddPolicy("AllowSpecificOrigins", policy => + { + policy.WithOrigins("http://localhost:3000", "https://localhost:3000") + .WithOrigins("http://localhost:4200", "https://localhost:4200") + .WithOrigins("http://localhost:5173", "https://localhost:5173") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); + + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + builder.Services.AddAuthorization(); builder.Services.AddAuthentication(configuration); builder.Services.AddApplicationServices(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddHealthChecks() + .AddNpgSql(builder.Configuration.GetConnectionString("GamesDb")!); + var app = builder.Build(); +app.UseGlobalExceptionHandling(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -38,11 +65,19 @@ await app.InitialiseDatabaseAsync(); } -app.MapGamesEndpoints(); - app.UseHttpsRedirection(); +app.UseCors(app.Environment.IsDevelopment() ? "AllowAll" : "AllowSpecificOrigins"); + app.UseAuthentication(); app.UseAuthorization(); +app.MapGamesEndpoints(); + +// Map health checks +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse +}); + app.Run(); \ No newline at end of file diff --git a/src/Games.Api/Properties/launchSettings.json b/src/Games.Api/Properties/launchSettings.json index 4be8e56..03187d4 100644 --- a/src/Games.Api/Properties/launchSettings.json +++ b/src/Games.Api/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchUrl": "swagger", "launchBrowser": true, - "applicationUrl": "http://localhost:5178", + "applicationUrl": "http://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -16,7 +16,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7152;http://localhost:5178", + "applicationUrl": "https://localhost:7001;http://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Games.Api/appsettings.Development.json b/src/Games.Api/appsettings.Development.json index a2878dd..4441bba 100644 --- a/src/Games.Api/appsettings.Development.json +++ b/src/Games.Api/appsettings.Development.json @@ -13,6 +13,6 @@ "Issuer": "https://id.gamesapi.com", "Audience": "https://games.gamesapi.com", "ExpirationInMinutes": 60, - "Key": "ThisIsNotARealKeyAndNeedsToBeReplacedGoodTryThoughAlsoABitMoreTextToMakeThisLongEnoughForProperValidation" + "Key": "SuperSecretKeyForDevelopmentUseOnly!@#$%^&*()1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" } } diff --git a/src/Games.Api/appsettings.json b/src/Games.Api/appsettings.json index 1f1fc42..49d4d16 100644 --- a/src/Games.Api/appsettings.json +++ b/src/Games.Api/appsettings.json @@ -20,7 +20,7 @@ "Issuer": "https://id.gamesapi.com", "Audience": "https://games.gamesapi.com", "ExpirationInMinutes": 60, - "Key": "ThisIsNotARealKeyAndNeedsToBeReplacedGoodTryThoughAlsoABitMoreTextToMakeThisLongEnoughForProperValidation" + "Key": "" }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/src/Gateway.Api/appsettings.json b/src/Gateway.Api/appsettings.json index b4176fe..8ad91b3 100644 --- a/src/Gateway.Api/appsettings.json +++ b/src/Gateway.Api/appsettings.json @@ -26,14 +26,14 @@ "games-cluster": { "Destinations": { "games-api": { - "Address": "http://localhost:7001/" + "Address": "https://localhost:7001/" } } }, "identity-cluster": { "Destinations": { "identity-api": { - "Address": "http://localhost:7002/" + "Address": "https://localhost:7002/" } } } diff --git a/src/Identity.Api/Identity.Api.csproj b/src/Identity.Api/Identity.Api.csproj index ec168fa..f726218 100644 --- a/src/Identity.Api/Identity.Api.csproj +++ b/src/Identity.Api/Identity.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Identity.Api/Program.cs b/src/Identity.Api/Program.cs index 2069071..2f72fc1 100644 --- a/src/Identity.Api/Program.cs +++ b/src/Identity.Api/Program.cs @@ -3,6 +3,7 @@ using HealthChecks.UI.Client; using Infrastructure; using Infrastructure.Data; +using Infrastructure.Middleware; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Scalar.AspNetCore; @@ -14,14 +15,39 @@ builder.Services.AddOpenApi(); builder.Services.AddOpenApiDocument(configuration, "Identity"); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowSpecificOrigins", policy => + { + policy.WithOrigins("http://localhost:3000", "https://localhost:3000") // React dev server + .WithOrigins("http://localhost:4200", "https://localhost:4200") // Angular dev server + .WithOrigins("http://localhost:5173", "https://localhost:5173") // Vite dev server + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); + + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + builder.Services.AddAuthorization(); builder.Services.AddAuthentication(configuration); builder.Services.AddIdentityApplicationServices(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddHealthChecks() + .AddNpgSql(builder.Configuration.GetConnectionString("Identity")!); + var app = builder.Build(); +app.UseGlobalExceptionHandling(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -42,6 +68,17 @@ app.UseHttpsRedirection(); +app.UseCors(app.Environment.IsDevelopment() ? "AllowAll" : "AllowSpecificOrigins"); + +app.UseAuthentication(); +app.UseAuthorization(); + app.MapIdentityEndpoints(); +// Map health checks +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse +}); + app.Run(); \ No newline at end of file diff --git a/src/Identity.Api/Properties/launchSettings.json b/src/Identity.Api/Properties/launchSettings.json index 58487b6..76fec57 100644 --- a/src/Identity.Api/Properties/launchSettings.json +++ b/src/Identity.Api/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://localhost:5018", + "applicationUrl": "http://localhost:5002", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -16,7 +16,7 @@ "dotnetRunMessages": true, "launchUrl": "swagger", "launchBrowser": true, - "applicationUrl": "https://localhost:7135;http://localhost:5018", + "applicationUrl": "https://localhost:7002;http://localhost:5002", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Identity.Api/appsettings.Development.json b/src/Identity.Api/appsettings.Development.json index 1030de2..99c8506 100644 --- a/src/Identity.Api/appsettings.Development.json +++ b/src/Identity.Api/appsettings.Development.json @@ -13,7 +13,7 @@ "Issuer": "https://id.gamesapi.com", "Audience": "https://games.gamesapi.com", "ExpirationInMinutes": 60, - "Key": "ThisIsNotARealKeyAndNeedsToBeReplacedGoodTryThoughAlsoABitMoreTextToMakeThisLongEnoughForProperValidation" + "Key": "SuperSecretKeyForDevelopmentUseOnly!@#$%^&*()1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" }, "Email": { "SenderEmail": "tester@user.com", diff --git a/src/Identity.Api/appsettings.json b/src/Identity.Api/appsettings.json index 3fd8288..b795a16 100644 --- a/src/Identity.Api/appsettings.json +++ b/src/Identity.Api/appsettings.json @@ -20,7 +20,7 @@ "Issuer": "https://id.gamesapi.com", "Audience": "https://games.gamesapi.com", "ExpirationInMinutes": 60, - "Key": "ThisIsNotARealKeyAndNeedsToBeReplacedGoodTryThoughAlsoABitMoreTextToMakeThisLongEnoughForProperValidation" + "Key": "" }, "Email": { "SenderEmail": "tester@user.com", diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 8ec49b2..7bcaa5e 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Infrastructure/Middleware/GlobalExceptionHandlingMiddleware.cs b/src/Infrastructure/Middleware/GlobalExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..da4461c --- /dev/null +++ b/src/Infrastructure/Middleware/GlobalExceptionHandlingMiddleware.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using FluentValidation; +using Application.Common.Exceptions; + +namespace Infrastructure.Middleware; + +public class GlobalExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) +{ + private readonly RequestDelegate _next = next; + private readonly ILogger _logger = logger; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception exception) + { + _logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message); + await HandleExceptionAsync(context, exception); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + + var (statusCode, message) = GetErrorResponse(exception); + context.Response.StatusCode = (int)statusCode; + + var response = new + { + error = new + { + message, + statusCode = (int)statusCode, + timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + } + }; + + var jsonResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await context.Response.WriteAsync(jsonResponse); + } + + private static (HttpStatusCode statusCode, string message) GetErrorResponse(Exception exception) + { + return exception switch + { + ValidationException validationEx => (HttpStatusCode.BadRequest, string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage))), + EmailAlreadyInUseException => (HttpStatusCode.Conflict, exception.Message), + ArgumentException => (HttpStatusCode.BadRequest, exception.Message), + UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Access denied"), + KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"), + InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message), + TimeoutException => (HttpStatusCode.RequestTimeout, "Request timeout"), + NotImplementedException => (HttpStatusCode.NotImplemented, "Feature not implemented"), + _ => (HttpStatusCode.InternalServerError, "An internal server error occurred") + }; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Middleware/MiddlewareExtensions.cs b/src/Infrastructure/Middleware/MiddlewareExtensions.cs new file mode 100644 index 0000000..54e60dd --- /dev/null +++ b/src/Infrastructure/Middleware/MiddlewareExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace Infrastructure.Middleware; + +public static class MiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} \ No newline at end of file diff --git a/tests/Application.Tests/CreateUserHandlerTests.cs b/tests/Application.Tests/CreateUserHandlerTests.cs index 115749a..830b5ef 100644 --- a/tests/Application.Tests/CreateUserHandlerTests.cs +++ b/tests/Application.Tests/CreateUserHandlerTests.cs @@ -13,6 +13,7 @@ using Npgsql; using NSubstitute; using Xunit; +using Application.Common.Exceptions; namespace Application.Tests; @@ -47,7 +48,7 @@ public CreateUserCommandHandlerTests() _mapper = config.CreateMapper(); _handler = new CreateUserCommandHandler( - _context, _passwordHasher, _fluentEmail, _linkFactory, _mapper); + _context, _passwordHasher, _fluentEmail, _linkFactory); } [Fact] @@ -84,9 +85,7 @@ public async Task Handle_ValidRequest_CreatesUserAndSendsVerificationEmail() _linkFactory.Received(1).Create(Arg.Any()); - result.Should().NotBeNull(); - result.Email.Should().Be(command.Email); - result.FirstName.Should().Be("John"); + result.Should().BeGreaterThan(0); } [Fact] @@ -118,8 +117,7 @@ public async Task Handle_ExistingEmail_ThrowsException() var act = async () => await _handler.Handle(command, CancellationToken.None); // Assert - await act.Should().ThrowAsync() - .WithMessage("The email is already in use"); + await act.Should().ThrowAsync(); } [Fact] @@ -134,21 +132,17 @@ public async Task Handle_UniqueViolationOnToken_ThrowsEmailInUseException() Password = "Pass123!" }; - _passwordHasher.HashPassword(default, command.Password).ReturnsForAnyArgs("hash123"); + _passwordHasher.HashPassword(Arg.Any(), command.Password).ReturnsForAnyArgs("hash123"); var dbUpdateEx = new DbUpdateException( "Unique violation", new PostgresException("duplicate key value", "23505", "XX000", "duplicate key value violates unique constraint")); - var callCount = 0; - - // Act & Assert await _handler.Handle(command, CancellationToken.None); var act = async () => await _handler.Handle(command, CancellationToken.None); - await act.Should().ThrowAsync() - .WithMessage("The email is already in use"); + await act.Should().ThrowAsync(); } [Fact]