From aa08df5822f502ff72daec746ce4575dded4e88a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 18 Nov 2025 01:31:57 +0100 Subject: [PATCH 01/11] Add support for major .NET version upgrades in update-packages command --- .../Commands/UpdatePackagesCommand.cs | 414 ++++++++++++------ 1 file changed, 277 insertions(+), 137 deletions(-) diff --git a/developer-cli/Commands/UpdatePackagesCommand.cs b/developer-cli/Commands/UpdatePackagesCommand.cs index 7d59a06a52..41cf14a098 100644 --- a/developer-cli/Commands/UpdatePackagesCommand.cs +++ b/developer-cli/Commands/UpdatePackagesCommand.cs @@ -58,7 +58,18 @@ private static async Task Execute(bool backend, bool frontend, bool dryRun, stri if (updateBackend) { - await UpdateNuGetPackagesAsync(dryRun, excludedPackages); + // Update central package management files + foreach (var propsFile in Directory.GetFiles(Configuration.SourceCodeFolder, "Directory.Packages.props", SearchOption.AllDirectories)) + { + await UpdateNuGetPackagesAsync(propsFile, "PackageVersion", dryRun, excludedPackages); + } + + // Update .csproj files that have inline PackageReference versions (not using central package management) + foreach (var csprojFile in Directory.GetFiles(Configuration.SourceCodeFolder, "*.csproj", SearchOption.AllDirectories)) + { + await UpdateNuGetPackagesAsync(csprojFile, "PackageReference", dryRun, excludedPackages, requireVersionAttribute: true); + } + UpdateAspireSdkVersion(dryRun); await UpdateDotnetToolsAsync(dryRun); } @@ -79,19 +90,21 @@ private static async Task Execute(bool backend, bool frontend, bool dryRun, stri } } - private static async Task UpdateNuGetPackagesAsync(bool dryRun, string[] excludedPackages) + private static async Task UpdateNuGetPackagesAsync(string filePath, string elementName, bool dryRun, string[] excludedPackages, bool requireVersionAttribute = false) { - var directoryPackagesPath = Path.Combine(Configuration.ApplicationFolder, "Directory.Packages.props"); - if (!File.Exists(directoryPackagesPath)) + if (!File.Exists(filePath)) return; + + var xDocument = XDocument.Load(filePath); + var packageElements = xDocument.Descendants(elementName).ToArray(); + + // Skip files that don't have any packages with Version attributes (e.g., csproj files using central package management) + if (requireVersionAttribute && !packageElements.Any(e => e.Attribute("Version") is not null)) { - AnsiConsole.MarkupLine($"[red]Directory.Packages.props not found at {directoryPackagesPath}[/]"); - Environment.Exit(1); + return; } - AnsiConsole.MarkupLine("Analyzing NuGet packages in Directory.Packages.props..."); - - var xDocument = XDocument.Load(directoryPackagesPath); - var packageElements = xDocument.Descendants("PackageVersion").ToArray(); + var fileName = Path.GetFileName(filePath); + AnsiConsole.MarkupLine($"Analyzing NuGet packages in {fileName}..."); var outdatedPackagesJson = await GetOutdatedPackagesJsonAsync(); @@ -297,7 +310,7 @@ private static async Task UpdateNuGetPackagesAsync(bool dryRun, string[] exclude } else { - AnsiConsole.MarkupLine("[green]All NuGet packages are up to date![/]"); + AnsiConsole.MarkupLine($"[green]All {fileName} NuGet packages are up to date![/]"); } if (packageUpdatesToApply.Count > 0 && !dryRun) @@ -307,26 +320,29 @@ private static async Task UpdateNuGetPackagesAsync(bool dryRun, string[] exclude update.Element.SetAttributeValue("Version", update.NewVersion); } + // Determine indent chars from the existing file + var existingIndent = xDocument.ToString().Contains("\n ") ? " " : " "; + // Save without XML declaration to preserve original format var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true, - IndentChars = " ", + IndentChars = existingIndent, Encoding = new UTF8Encoding(false), // No BOM Async = true }; - await using (var writer = XmlWriter.Create(directoryPackagesPath, settings)) + await using (var writer = XmlWriter.Create(filePath, settings)) { await xDocument.SaveAsync(writer, CancellationToken.None); } - AnsiConsole.MarkupLine("[green]Directory.Packages.props updated successfully![/]"); + AnsiConsole.MarkupLine($"[green]{fileName} updated successfully![/]"); } else if (packageUpdatesToApply.Count > 0) { - AnsiConsole.MarkupLine($"[blue]Would update {packageUpdatesToApply.Count} NuGet package(s) (dry-run mode)[/]"); + AnsiConsole.MarkupLine($"[blue]Would update {packageUpdatesToApply.Count} {fileName} NuGet package(s) (dry-run mode)[/]"); } } @@ -928,11 +944,11 @@ private static void UpdateAspireSdkVersion(bool dryRun) // Read file content to check for SDK var fileContent = File.ReadAllText(appHostPath); - // Use regex to find the Aspire SDK version - var sdkVersionMatch = Regex.Match(fileContent, @""); - if (!sdkVersionMatch.Success) return; + // Use regex to find the Aspire SDK version in Project attribute + var projectSdkMatch = Regex.Match(fileContent, @""); + if (!projectSdkMatch.Success) return; - var currentSdkVersion = sdkVersionMatch.Groups[1].Value; + var currentSdkVersion = projectSdkMatch.Groups[1].Value; // Get the Aspire.Hosting.AppHost version from Directory.Packages.props var directoryPackagesPath = Path.Combine(Configuration.ApplicationFolder, "Directory.Packages.props"); @@ -957,10 +973,10 @@ private static void UpdateAspireSdkVersion(bool dryRun) if (!dryRun) { - // Replace only the version string, preserving formatting + // Replace Project element SDK attribute version var updatedContent = fileContent.Replace( - $@"", - $@"" + $@"", + $@"" ); // Write back preserving original formatting @@ -1022,146 +1038,153 @@ private static void UpdateBiomeSchemaVersion(bool dryRun, string? newBiomeVersio private static async Task UpdateDotnetToolsAsync(bool dryRun) { - var dotnetToolsPath = Path.Combine(Configuration.ApplicationFolder, "dotnet-tools.json"); - if (!File.Exists(dotnetToolsPath)) return; + // Find all dotnet-tools.json files + var dotnetToolsFiles = Directory.GetFiles(Configuration.SourceCodeFolder, "dotnet-tools.json", SearchOption.AllDirectories); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("Analyzing .NET tools in dotnet-tools.json..."); + if (dotnetToolsFiles.Length == 0) return; - var toolsJson = await File.ReadAllTextAsync(dotnetToolsPath); - var toolsDocument = JsonDocument.Parse(toolsJson); + foreach (var dotnetToolsPath in dotnetToolsFiles) + { + var fileName = Path.GetFileName(dotnetToolsPath); + var relativePath = Path.GetRelativePath(Configuration.SourceCodeFolder, dotnetToolsPath); - var table = new Table(); - table.AddColumn("Tool"); - table.AddColumn("Current Version"); - table.AddColumn("Latest Version"); - table.AddColumn("Update Type"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"Analyzing .NET tools in {relativePath}..."); - var dotnetToolUpdatesToApply = new List<(string toolName, string currentVersion, string latestVersion)>(); + var toolsJson = await File.ReadAllTextAsync(dotnetToolsPath); + var toolsDocument = JsonDocument.Parse(toolsJson); - if (!toolsDocument.RootElement.TryGetProperty("tools", out var tools)) - { - return; - } + var table = new Table(); + table.AddColumn("Tool"); + table.AddColumn("Current Version"); + table.AddColumn("Latest Version"); + table.AddColumn("Update Type"); - foreach (var tool in tools.EnumerateObject()) - { - var toolName = tool.Name; + var dotnetToolUpdatesToApply = new List<(string toolName, string currentVersion, string latestVersion)>(); - if (!tool.Value.TryGetProperty("version", out var versionElement)) + if (!toolsDocument.RootElement.TryGetProperty("tools", out var tools)) { continue; } - var currentVersion = versionElement.GetString(); - if (currentVersion is null) + foreach (var tool in tools.EnumerateObject()) { - continue; - } - - // Get latest version from NuGet API - var latestVersion = await GetLatestVersionFromNuGetApi(toolName); + var toolName = tool.Name; - if (latestVersion is null || latestVersion == currentVersion || !IsNewerVersion(latestVersion, currentVersion)) - { - continue; - } + if (!tool.Value.TryGetProperty("version", out var versionElement)) + { + continue; + } - // Handle prerelease versions - if (IsPreReleaseVersion(latestVersion) && !IsPreReleaseVersion(currentVersion)) - { - // Try to find a stable version - var stableVersion = await GetLatestStableVersionFromNuGetApi(toolName); - if (stableVersion is null || stableVersion == currentVersion || !IsNewerVersion(stableVersion, currentVersion)) + var currentVersion = versionElement.GetString(); + if (currentVersion is null) { - continue; // Skip if no stable update available + continue; } - latestVersion = stableVersion; - } + // Get latest version from NuGet API + var latestVersion = await GetLatestVersionFromNuGetApi(toolName); - // Check update type - var updateType = GetUpdateType(currentVersion, latestVersion); - BackendSummary.IncrementUpdateType(updateType); + if (latestVersion is null || latestVersion == currentVersion || !IsNewerVersion(latestVersion, currentVersion)) + { + continue; + } - var statusColor = updateType switch - { - UpdateType.Major => "[yellow]Major[/]", - UpdateType.Minor => "[green]Minor[/]", - UpdateType.Patch => "Patch", - _ => "[green]Minor[/]" - }; + // Handle prerelease versions + if (IsPreReleaseVersion(latestVersion) && !IsPreReleaseVersion(currentVersion)) + { + // Try to find a stable version + var stableVersion = await GetLatestStableVersionFromNuGetApi(toolName); + if (stableVersion is null || stableVersion == currentVersion || !IsNewerVersion(stableVersion, currentVersion)) + { + continue; // Skip if no stable update available + } - table.AddRow(toolName, currentVersion, latestVersion, statusColor); - dotnetToolUpdatesToApply.Add((toolName, currentVersion, latestVersion)); - } + latestVersion = stableVersion; + } - if (table.Rows.Count > 0) - { - AnsiConsole.Write(table); - } - else - { - AnsiConsole.MarkupLine("[green]All .NET tools are up to date![/]"); - return; - } + // Check update type + var updateType = GetUpdateType(currentVersion, latestVersion); + BackendSummary.IncrementUpdateType(updateType); - if (dotnetToolUpdatesToApply.Count > 0 && !dryRun) - { - // Parse and update the JSON - using var jsonDoc = JsonDocument.Parse(toolsJson); - using var stream = new MemoryStream(); - await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + var statusColor = updateType switch + { + UpdateType.Major => "[yellow]Major[/]", + UpdateType.Minor => "[green]Minor[/]", + UpdateType.Patch => "Patch", + _ => "[green]Minor[/]" + }; + + table.AddRow(toolName, currentVersion, latestVersion, statusColor); + dotnetToolUpdatesToApply.Add((toolName, currentVersion, latestVersion)); + } + + if (table.Rows.Count > 0) { - writer.WriteStartObject(); + AnsiConsole.Write(table); + } + else + { + AnsiConsole.MarkupLine($"[green]All .NET tools in {relativePath} are up to date![/]"); + } - foreach (var property in jsonDoc.RootElement.EnumerateObject()) + if (dotnetToolUpdatesToApply.Count > 0 && !dryRun) + { + // Parse and update the JSON + using var jsonDoc = JsonDocument.Parse(toolsJson); + using var stream = new MemoryStream(); + await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) { - if (property.Name == "tools") - { - writer.WritePropertyName("tools"); - writer.WriteStartObject(); + writer.WriteStartObject(); - foreach (var tool in property.Value.EnumerateObject()) + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + if (property.Name == "tools") { - writer.WritePropertyName(tool.Name); + writer.WritePropertyName("tools"); writer.WriteStartObject(); - var updateInfo = dotnetToolUpdatesToApply.FirstOrDefault(u => u.toolName == tool.Name); - - foreach (var toolProperty in tool.Value.EnumerateObject()) + foreach (var tool in property.Value.EnumerateObject()) { - if (toolProperty.Name == "version" && updateInfo != default) - { - writer.WriteString("version", updateInfo.latestVersion); - } - else + writer.WritePropertyName(tool.Name); + writer.WriteStartObject(); + + var updateInfo = dotnetToolUpdatesToApply.FirstOrDefault(u => u.toolName == tool.Name); + + foreach (var toolProperty in tool.Value.EnumerateObject()) { - toolProperty.WriteTo(writer); + if (toolProperty.Name == "version" && updateInfo != default) + { + writer.WriteString("version", updateInfo.latestVersion); + } + else + { + toolProperty.WriteTo(writer); + } } + + writer.WriteEndObject(); } writer.WriteEndObject(); } - - writer.WriteEndObject(); - } - else - { - property.WriteTo(writer); + else + { + property.WriteTo(writer); + } } + + writer.WriteEndObject(); } - writer.WriteEndObject(); + var updatedJson = Encoding.UTF8.GetString(stream.ToArray()); + await File.WriteAllTextAsync(dotnetToolsPath, updatedJson); + AnsiConsole.MarkupLine($"[green]{relativePath} updated successfully![/]"); + } + else if (dotnetToolUpdatesToApply.Count > 0) + { + AnsiConsole.MarkupLine($"[blue]Would update {dotnetToolUpdatesToApply.Count} .NET tool(s) in {relativePath} (dry-run mode)[/]"); } - - var updatedJson = Encoding.UTF8.GetString(stream.ToArray()); - await File.WriteAllTextAsync(dotnetToolsPath, updatedJson); - AnsiConsole.MarkupLine("[green]dotnet-tools.json updated successfully![/]"); - } - else if (dotnetToolUpdatesToApply.Count > 0) - { - AnsiConsole.MarkupLine($"[blue]Would update {dotnetToolUpdatesToApply.Count} .NET tool(s) (dry-run mode)[/]"); } } @@ -1173,10 +1196,15 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec var currentVersion = globalJsonDoc.RootElement.GetProperty("sdk").GetProperty("version").GetString()!; // Get latest .NET SDK version from the official releases + // First, check if there's a newer major version available var currentMajor = GetMajorVersion(currentVersion); - var latestInMajor = await GetLatestDotnetSdkVersion(currentMajor); + var latestMajorVersion = await GetLatestDotnetMajorVersion(); - if (latestInMajor == currentVersion) + // Determine which version to target - use newer major if available + var targetMajor = latestMajorVersion > currentMajor ? latestMajorVersion : currentMajor; + var latestVersion = await GetLatestDotnetSdkVersion(targetMajor); + + if (latestVersion == currentVersion) { if (!earlyCheck) { @@ -1187,7 +1215,7 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec } // Check if the latest version is installed locally - var isInstalledLocally = IsDotnetSdkInstalledLocally(latestInMajor); + var isInstalledLocally = IsDotnetSdkInstalledLocally(latestVersion); // Early check - only care about blocking if SDK not installed if (earlyCheck) @@ -1195,22 +1223,22 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec if (isInstalledLocally) return; // If installed, we'll update it after other updates AnsiConsole.MarkupLine($""" - [red]❌ Cannot update .NET SDK: version {latestInMajor} is not installed locally![/] - [yellow] Install it first: brew upgrade dotnet-sdk (macOS) or winget upgrade Microsoft.DotNet.SDK.{currentMajor} (Windows)[/] + [red]❌ Cannot update .NET SDK: version {latestVersion} is not installed locally![/] + [yellow] Install it first: brew upgrade dotnet-sdk (macOS) or winget upgrade Microsoft.DotNet.SDK.{targetMajor} (Windows)[/] """ ); Environment.Exit(1); } - AnsiConsole.MarkupLine($"[blue]A newer .NET SDK version is available: {latestInMajor} (current: {currentVersion})[/]"); + AnsiConsole.MarkupLine($"[blue]A newer .NET SDK version is available: {latestVersion} (current: {currentVersion})[/]"); // Late check - show status information if (!isInstalledLocally) { AnsiConsole.MarkupLine( $""" - [red] ⚠️ .NET SDK {latestInMajor} is NOT installed on your machine![/] - [yellow] Update .NET: brew upgrade dotnet-sdk (macOS) or winget upgrade Microsoft.DotNet.SDK.{currentMajor} (Windows)[/] + [red] ⚠️ .NET SDK {latestVersion} is NOT installed on your machine![/] + [yellow] Update .NET: brew upgrade dotnet-sdk (macOS) or winget upgrade Microsoft.DotNet.SDK.{targetMajor} (Windows)[/] """ ); } @@ -1218,13 +1246,98 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec // Actually update .NET SDK if not in dry-run mode and SDK is installed if (!dryRun && isInstalledLocally) { - // Update global.json + // Update all global.json files + await UpdateAllGlobalJsonFiles(latestVersion); + + // Also update Prerequisite.cs + await UpdatePrerequisiteDotnetVersion(latestVersion); + + // Update TargetFramework in all csproj files + await UpdateTargetFrameworkInAllCsprojFiles(latestVersion); + + // Update ContainerBaseImage in all csproj files + await UpdateContainerBaseImageInAllCsprojFiles(latestVersion); + } + } + + private static async Task UpdateTargetFrameworkInAllCsprojFiles(string newSdkVersion) + { + var newMajor = GetMajorVersion(newSdkVersion); + var newTargetFramework = $"net{newMajor}.0"; + + var pattern = @"net\d+\.0(-\w+)?"; + var replacement = $"{newTargetFramework}$1"; + + var updatedFiles = new List(); + var csprojFiles = Directory.GetFiles(Configuration.SourceCodeFolder, "*.csproj", SearchOption.AllDirectories); + + foreach (var csprojPath in csprojFiles) + { + var content = await File.ReadAllTextAsync(csprojPath); + var updatedContent = Regex.Replace(content, pattern, replacement); + + if (updatedContent != content) + { + await File.WriteAllTextAsync(csprojPath, updatedContent); + updatedFiles.Add(Path.GetRelativePath(Configuration.SourceCodeFolder, csprojPath)); + } + } + + if (updatedFiles.Count > 0) + { + AnsiConsole.MarkupLine($"[green]✓ Updated TargetFramework to {newTargetFramework} in {updatedFiles.Count} csproj file(s)[/]"); + } + } + + private static async Task UpdateContainerBaseImageInAllCsprojFiles(string newSdkVersion) + { + var newMajor = GetMajorVersion(newSdkVersion); + + // Pattern to match ContainerBaseImage tags with dotnet base images + // Captures: mcr.microsoft.com/dotnet/{runtime}:{version}-{variant} + var pattern = @"mcr\.microsoft\.com/dotnet/(aspnet|runtime):\d+\.0(-[^<]+)?"; + var replacement = $"mcr.microsoft.com/dotnet/$1:{newMajor}.0$2"; + + var updatedFiles = new List(); + var csprojFiles = Directory.GetFiles(Configuration.SourceCodeFolder, "*.csproj", SearchOption.AllDirectories); + + foreach (var csprojPath in csprojFiles) + { + var content = await File.ReadAllTextAsync(csprojPath); + var updatedContent = Regex.Replace(content, pattern, replacement); + + if (updatedContent != content) + { + await File.WriteAllTextAsync(csprojPath, updatedContent); + updatedFiles.Add(Path.GetRelativePath(Configuration.SourceCodeFolder, csprojPath)); + } + } + + if (updatedFiles.Count > 0) + { + AnsiConsole.MarkupLine($"[green]✓ Updated ContainerBaseImage to .NET {newMajor}.0 in {updatedFiles.Count} csproj file(s)[/]"); + } + } + + private static async Task UpdateAllGlobalJsonFiles(string newVersion) + { + var globalJsonFiles = Directory.GetFiles(Configuration.SourceCodeFolder, "global.json", SearchOption.AllDirectories); + var updatedFiles = new List(); + + foreach (var filePath in globalJsonFiles) + { + var content = await File.ReadAllTextAsync(filePath); + var jsonDocument = JsonDocument.Parse(content); + var currentVersion = jsonDocument.RootElement.GetProperty("sdk").GetProperty("version").GetString()!; + + if (currentVersion == newVersion) continue; + using var stream = new MemoryStream(); await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) { writer.WriteStartObject(); - foreach (var property in globalJsonDoc.RootElement.EnumerateObject()) + foreach (var property in jsonDocument.RootElement.EnumerateObject()) { if (property.Name != "sdk") { @@ -1239,7 +1352,7 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec { if (sdkProperty.Name == "version") { - writer.WriteString("version", latestInMajor); + writer.WriteString("version", newVersion); } else { @@ -1254,11 +1367,13 @@ private static async Task CheckDotnetSdkVersionAsync(bool dryRun, bool earlyChec } var updatedJson = Encoding.UTF8.GetString(stream.ToArray()); - await File.WriteAllTextAsync(globalJsonPath, updatedJson); - AnsiConsole.MarkupLine($"\n[green]✓ Updated .NET SDK version from {currentVersion} to {latestInMajor} in global.json[/]"); + await File.WriteAllTextAsync(filePath, updatedJson); + updatedFiles.Add(Path.GetRelativePath(Configuration.SourceCodeFolder, filePath)); + } - // Also update Prerequisite.cs - await UpdatePrerequisiteDotnetVersion(latestInMajor); + if (updatedFiles.Count > 0) + { + AnsiConsole.MarkupLine($"[green]✓ Updated .NET SDK version to {newVersion} in {updatedFiles.Count} global.json file(s)[/]"); } } @@ -1278,6 +1393,31 @@ private static bool IsDotnetSdkInstalledLocally(string version) return false; } + private static async Task GetLatestDotnetMajorVersion() + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PlatformPlatform-CLI/1.0"); + + // Get the releases index + const string dotnetReleaseBaseUrl = "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata"; + var response = await httpClient.GetStringAsync($"{dotnetReleaseBaseUrl}/releases-index.json"); + var releasesIndex = JsonDocument.Parse(response); + + // Find the latest major version + var latestMajor = releasesIndex.RootElement + .GetProperty("releases-index") + .EnumerateArray() + .Select(release => + { + var channelVersion = release.GetProperty("channel-version").GetString()!; + var parts = channelVersion.Split('.'); + return int.Parse(parts[0]); + }) + .Max(); + + return latestMajor; + } + private static async Task GetLatestDotnetSdkVersion(int majorVersion) { using var httpClient = new HttpClient(); From 5d0fd1b0cb106eb1e0284276548c9188e2f5d99e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 18 Nov 2025 10:43:45 +0100 Subject: [PATCH 02/11] Upgrade to .NET 10 --- .github/workflows/app-gateway.yml | 2 +- application/AppGateway/AppGateway.csproj | 2 +- application/AppHost/AppHost.csproj | 2 +- application/Directory.Packages.props | 68 +++++++++---------- .../Api/AccountManagement.Api.csproj | 2 +- .../Core/AccountManagement.csproj | 2 +- .../Tests/AccountManagement.Tests.csproj | 2 +- .../WebApp/AccountManagement.WebApp.esproj | 2 +- .../Workers/AccountManagement.Workers.csproj | 2 +- .../back-office/Api/BackOffice.Api.csproj | 2 +- .../back-office/Core/BackOffice.csproj | 2 +- .../back-office/Tests/BackOffice.Tests.csproj | 2 +- .../WebApp/BackOffice.WebApp.esproj | 2 +- .../Workers/BackOffice.Workers.csproj | 2 +- application/dotnet-tools.json | 6 +- application/global.json | 3 +- .../ApiDependencyConfiguration.cs | 2 +- .../SharedKernel/SharedKernel.csproj | 4 +- .../Tests/SharedKernel.Tests.csproj | 2 +- .../shared-webapp/SharedKernel.WebApp.esproj | 2 +- developer-cli/DeveloperCli.csproj | 17 +++-- developer-cli/Installation/Prerequisite.cs | 2 +- developer-cli/dotnet-tools.json | 2 +- developer-cli/global.json | 3 +- 24 files changed, 65 insertions(+), 72 deletions(-) diff --git a/.github/workflows/app-gateway.yml b/.github/workflows/app-gateway.yml index 177cc63eaa..95fc60dbc7 100644 --- a/.github/workflows/app-gateway.yml +++ b/.github/workflows/app-gateway.yml @@ -118,7 +118,7 @@ jobs: - name: Setup .NET Core SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + global-json-file: application/global.json - name: Restore .NET Tools working-directory: application diff --git a/application/AppGateway/AppGateway.csproj b/application/AppGateway/AppGateway.csproj index 0a3dcc6973..632d9c3721 100644 --- a/application/AppGateway/AppGateway.csproj +++ b/application/AppGateway/AppGateway.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.AppGateway PlatformPlatform.AppGateway enable diff --git a/application/AppHost/AppHost.csproj b/application/AppHost/AppHost.csproj index aa52d63437..6a01c7eb38 100644 --- a/application/AppHost/AppHost.csproj +++ b/application/AppHost/AppHost.csproj @@ -4,7 +4,7 @@ Exe - net9.0 + net10.0 enable enable platformplatform-f817f2a1-ac57-4756-aef2-a57ca864bbd3 diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index 78227ac3ed..cde299a9e2 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -6,16 +6,16 @@ - - - - - + + + + + - - + + - + @@ -31,46 +31,44 @@ - + - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + diff --git a/application/account-management/Api/AccountManagement.Api.csproj b/application/account-management/Api/AccountManagement.Api.csproj index 269313eca2..7b248b3dce 100644 --- a/application/account-management/Api/AccountManagement.Api.csproj +++ b/application/account-management/Api/AccountManagement.Api.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.AccountManagement.Api PlatformPlatform.AccountManagement.Api enable diff --git a/application/account-management/Core/AccountManagement.csproj b/application/account-management/Core/AccountManagement.csproj index 172dd5e580..c2573c6b4b 100644 --- a/application/account-management/Core/AccountManagement.csproj +++ b/application/account-management/Core/AccountManagement.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.AccountManagement PlatformPlatform.AccountManagement enable diff --git a/application/account-management/Tests/AccountManagement.Tests.csproj b/application/account-management/Tests/AccountManagement.Tests.csproj index 1af9bf707a..14fafc9409 100644 --- a/application/account-management/Tests/AccountManagement.Tests.csproj +++ b/application/account-management/Tests/AccountManagement.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.AccountManagement.Tests PlatformPlatform.AccountManagement.Tests enable diff --git a/application/account-management/WebApp/AccountManagement.WebApp.esproj b/application/account-management/WebApp/AccountManagement.WebApp.esproj index b8a1977791..070e80c683 100644 --- a/application/account-management/WebApp/AccountManagement.WebApp.esproj +++ b/application/account-management/WebApp/AccountManagement.WebApp.esproj @@ -3,7 +3,7 @@ false false - net9.0 + net10.0 $(DefaultItemExcludes);dist\**;*.config.*;*.d.ts $(MSBuildProjectDirectory)\..\..\package-lock.json diff --git a/application/account-management/Workers/AccountManagement.Workers.csproj b/application/account-management/Workers/AccountManagement.Workers.csproj index 7c207558e1..4bcae22d56 100644 --- a/application/account-management/Workers/AccountManagement.Workers.csproj +++ b/application/account-management/Workers/AccountManagement.Workers.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.AccountManagement.Workers PlatformPlatform.AccountManagement.Workers enable diff --git a/application/back-office/Api/BackOffice.Api.csproj b/application/back-office/Api/BackOffice.Api.csproj index 9822eb2d9a..e96eed1e86 100644 --- a/application/back-office/Api/BackOffice.Api.csproj +++ b/application/back-office/Api/BackOffice.Api.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.BackOffice.Api PlatformPlatform.BackOffice.Api enable diff --git a/application/back-office/Core/BackOffice.csproj b/application/back-office/Core/BackOffice.csproj index 7cfbb752c0..062a9987fb 100644 --- a/application/back-office/Core/BackOffice.csproj +++ b/application/back-office/Core/BackOffice.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.BackOffice PlatformPlatform.BackOffice enable diff --git a/application/back-office/Tests/BackOffice.Tests.csproj b/application/back-office/Tests/BackOffice.Tests.csproj index c38e109e1e..1dc699e5d3 100644 --- a/application/back-office/Tests/BackOffice.Tests.csproj +++ b/application/back-office/Tests/BackOffice.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.BackOffice.Tests PlatformPlatform.BackOffice.Tests enable diff --git a/application/back-office/WebApp/BackOffice.WebApp.esproj b/application/back-office/WebApp/BackOffice.WebApp.esproj index b8a1977791..070e80c683 100644 --- a/application/back-office/WebApp/BackOffice.WebApp.esproj +++ b/application/back-office/WebApp/BackOffice.WebApp.esproj @@ -3,7 +3,7 @@ false false - net9.0 + net10.0 $(DefaultItemExcludes);dist\**;*.config.*;*.d.ts $(MSBuildProjectDirectory)\..\..\package-lock.json diff --git a/application/back-office/Workers/BackOffice.Workers.csproj b/application/back-office/Workers/BackOffice.Workers.csproj index fa7e07ef2a..fd2f9aa0a4 100644 --- a/application/back-office/Workers/BackOffice.Workers.csproj +++ b/application/back-office/Workers/BackOffice.Workers.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.BackOffice.Workers PlatformPlatform.BackOffice.Workers enable diff --git a/application/dotnet-tools.json b/application/dotnet-tools.json index ca04ce224f..903122f510 100644 --- a/application/dotnet-tools.json +++ b/application/dotnet-tools.json @@ -11,15 +11,15 @@ "commands": ["dotnet-dotcover"] }, "jetbrains.resharper.globaltools": { - "version": "2025.2.4", + "version": "2025.3.0.2", "commands": ["jb"] }, "dotnet-ef": { - "version": "9.0.10", + "version": "10.0.0", "commands": ["dotnet-ef"] }, "aspire.cli": { - "version": "9.5.2", + "version": "13.0.0", "commands": ["aspire"] } } diff --git a/application/global.json b/application/global.json index 99e8a932cd..376af49c07 100644 --- a/application/global.json +++ b/application/global.json @@ -1,6 +1,5 @@ { "sdk": { - "version": "9.0.306", - "rollForward": "latestMinor" + "version": "10.0.100" } } diff --git a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs index 1ea4157153..5ed543d9fe 100644 --- a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs @@ -191,7 +191,7 @@ public static IServiceCollection AddHttpForwardHeaders(this IServiceCollection s { // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); } ); diff --git a/application/shared-kernel/SharedKernel/SharedKernel.csproj b/application/shared-kernel/SharedKernel/SharedKernel.csproj index 346d22b8fb..039bfe0313 100644 --- a/application/shared-kernel/SharedKernel/SharedKernel.csproj +++ b/application/shared-kernel/SharedKernel/SharedKernel.csproj @@ -2,7 +2,7 @@ Library - net9.0 + net10.0 enable enable true @@ -39,8 +39,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - diff --git a/application/shared-kernel/Tests/SharedKernel.Tests.csproj b/application/shared-kernel/Tests/SharedKernel.Tests.csproj index b30b56b4de..60d8cd3bfd 100644 --- a/application/shared-kernel/Tests/SharedKernel.Tests.csproj +++ b/application/shared-kernel/Tests/SharedKernel.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 PlatformPlatform.SharedKernel.Tests PlatformPlatform.SharedKernel.Tests enable diff --git a/application/shared-webapp/SharedKernel.WebApp.esproj b/application/shared-webapp/SharedKernel.WebApp.esproj index 3883f86b84..1f2563d575 100644 --- a/application/shared-webapp/SharedKernel.WebApp.esproj +++ b/application/shared-webapp/SharedKernel.WebApp.esproj @@ -3,7 +3,7 @@ false false - net9.0 + net10.0 $(DefaultItemExcludes);**\dist\**;*.config.*;*.d.ts $(MSBuildProjectDirectory)\..\..\package-lock.json diff --git a/developer-cli/DeveloperCli.csproj b/developer-cli/DeveloperCli.csproj index 25caf293ce..985d1dc303 100644 --- a/developer-cli/DeveloperCli.csproj +++ b/developer-cli/DeveloperCli.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 pp true PlatformPlatform.DeveloperCli @@ -13,17 +13,16 @@ - - - - + + + + - - - + + + - diff --git a/developer-cli/Installation/Prerequisite.cs b/developer-cli/Installation/Prerequisite.cs index afaaddaefa..33bbf6625b 100644 --- a/developer-cli/Installation/Prerequisite.cs +++ b/developer-cli/Installation/Prerequisite.cs @@ -7,7 +7,7 @@ namespace PlatformPlatform.DeveloperCli.Installation; public abstract record Prerequisite { - public static readonly Prerequisite Dotnet = new CommandLineToolPrerequisite("dotnet", "dotnet", new Version(9, 0, 306)); + public static readonly Prerequisite Dotnet = new CommandLineToolPrerequisite("dotnet", "dotnet", new Version(10, 0, 100)); public static readonly Prerequisite Docker = new CommandLineToolPrerequisite("docker", "Docker", new Version(28, 5, 1)); public static readonly Prerequisite Node = new CommandLineToolPrerequisite("node", "NodeJS", new Version(24, 10, 0)); public static readonly Prerequisite AzureCli = new CommandLineToolPrerequisite("az", "Azure CLI", new Version(2, 79)); diff --git a/developer-cli/dotnet-tools.json b/developer-cli/dotnet-tools.json index fdb070e147..0b7dd0e88c 100644 --- a/developer-cli/dotnet-tools.json +++ b/developer-cli/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2024.3.6", + "version": "2025.3.0.2", "commands": ["jb"] } } diff --git a/developer-cli/global.json b/developer-cli/global.json index 72e9873166..376af49c07 100644 --- a/developer-cli/global.json +++ b/developer-cli/global.json @@ -1,6 +1,5 @@ { "sdk": { - "version": "9.0.200", - "rollForward": "latestMinor" + "version": "10.0.100" } } From 3b39beaa10d2f4167e30cdbe31b2f11f0198cdec Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 18 Nov 2025 11:32:55 +0100 Subject: [PATCH 03/11] Fix .NET 10 compatibility issues with EF Core and C# 14 --- .../Core/Features/Tenants/Domain/TenantRepository.cs | 2 +- .../Core/Features/Users/Domain/UserRepository.cs | 2 +- .../Configuration/SharedInfrastructureConfiguration.cs | 6 +++++- .../SharedKernel/EntityFramework/ModelBuilderExtensions.cs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/application/account-management/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account-management/Core/Features/Tenants/Domain/TenantRepository.cs index 025a460568..e7b4ad897d 100644 --- a/application/account-management/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account-management/Core/Features/Tenants/Domain/TenantRepository.cs @@ -27,6 +27,6 @@ public async Task GetCurrentTenantAsync(CancellationToken cancellationTo public async Task GetByIdsAsync(TenantId[] ids, CancellationToken cancellationToken) { - return await DbSet.Where(t => ids.Contains(t.Id)).ToArrayAsync(cancellationToken); + return await DbSet.Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); } } diff --git a/application/account-management/Core/Features/Users/Domain/UserRepository.cs b/application/account-management/Core/Features/Users/Domain/UserRepository.cs index 5d57c52a12..bd7f9227cf 100644 --- a/application/account-management/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account-management/Core/Features/Users/Domain/UserRepository.cs @@ -84,7 +84,7 @@ public Task CountTenantUsersAsync(TenantId tenantId, CancellationToken canc public async Task GetByIdsAsync(UserId[] ids, CancellationToken cancellationToken) { - return await DbSet.Where(u => ids.Contains(u.Id)).ToArrayAsync(cancellationToken); + return await DbSet.Where(u => ids.AsEnumerable().Contains(u.Id)).ToArrayAsync(cancellationToken); } public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken) diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index f2c31ac93b..3237474378 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -59,7 +59,11 @@ private static IHostApplicationBuilder ConfigureDatabaseContext(this IHostApp ? Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING") : builder.Configuration.GetConnectionString(connectionName); - builder.Services.AddAzureSql(connectionString); + builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, sqlOptions => + sqlOptions.UseCompatibilityLevel(150) // SQL Server 2019 compatibility to avoid native JSON type + ) + ); return builder; } diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs index 63509037cb..299c38e164 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs @@ -48,7 +48,7 @@ public static void MapStronglyTypedNullableId( { var nullConstant = Expression.Constant(null, typeof(TValue)); var idParameter = Expression.Parameter(typeof(TId), "id"); - var idValueProperty = Expression.Property(idParameter, nameof(StronglyTypedId.Value)); + var idValueProperty = Expression.Property(idParameter, "Value"); var idCoalesceExpression = Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); From 7dae26c817e2153f6cfb0aba278b866edcdd21a3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 21 Nov 2025 14:40:02 +0100 Subject: [PATCH 04/11] Upgrade Docker images to use mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra --- application/AppGateway/Dockerfile | 2 +- application/account-management/Api/Dockerfile | 5 +---- application/account-management/Workers/Dockerfile | 5 +---- application/back-office/Api/Dockerfile | 5 +---- application/back-office/Workers/Dockerfile | 5 +---- 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/application/AppGateway/Dockerfile b/application/AppGateway/Dockerfile index bb8e549b75..1ccec33906 100644 --- a/application/AppGateway/Dockerfile +++ b/application/AppGateway/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled WORKDIR /app COPY ./AppGateway/publish . diff --git a/application/account-management/Api/Dockerfile b/application/account-management/Api/Dockerfile index efd0c0c264..40678b1be0 100644 --- a/application/account-management/Api/Dockerfile +++ b/application/account-management/Api/Dockerfile @@ -1,7 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine - -RUN apk add --no-cache icu-libs -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra WORKDIR /app COPY ./Api/publish . diff --git a/application/account-management/Workers/Dockerfile b/application/account-management/Workers/Dockerfile index 69ac9c7507..8dc536fa33 100644 --- a/application/account-management/Workers/Dockerfile +++ b/application/account-management/Workers/Dockerfile @@ -1,7 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine - -RUN apk add --no-cache icu-libs -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra WORKDIR /app COPY ./Workers/publish . diff --git a/application/back-office/Api/Dockerfile b/application/back-office/Api/Dockerfile index 831cf963db..b0a5686234 100644 --- a/application/back-office/Api/Dockerfile +++ b/application/back-office/Api/Dockerfile @@ -1,7 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine - -RUN apk add --no-cache icu-libs -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra WORKDIR /app COPY ./Api/publish . diff --git a/application/back-office/Workers/Dockerfile b/application/back-office/Workers/Dockerfile index b306e4aedb..6593695561 100644 --- a/application/back-office/Workers/Dockerfile +++ b/application/back-office/Workers/Dockerfile @@ -1,7 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine - -RUN apk add --no-cache icu-libs -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra WORKDIR /app COPY ./Workers/publish . From a3b691ebb6103875cb2df3b5f9e90af9f3a783b9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 21 Nov 2025 14:40:46 +0100 Subject: [PATCH 05/11] Update Aspire to use Aspire.Hosting.JavaScript over Aspire.Hosting.NodeJS and remove .esproj references --- application/AppHost/AppHost.csproj | 9 ++------- application/AppHost/Program.cs | 2 +- application/Directory.Packages.props | 3 +-- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/application/AppHost/AppHost.csproj b/application/AppHost/AppHost.csproj index 6a01c7eb38..089f965c77 100644 --- a/application/AppHost/AppHost.csproj +++ b/application/AppHost/AppHost.csproj @@ -1,6 +1,4 @@ - - - + Exe @@ -12,19 +10,16 @@ - - - - + diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index 4abda9b023..d36310cd61 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -48,7 +48,7 @@ CreateBlobContainer("logos"); var frontendBuild = builder - .AddNpmApp("frontend-build", "../") + .AddJavaScriptApp("frontend-build", "../") .WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword); var accountManagementDatabase = sqlServer diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index cde299a9e2..b728a471a2 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -8,10 +8,9 @@ - - + From 2620f47c03f40f0f6e5a0d65049242387e5caa10 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 11:52:47 +0100 Subject: [PATCH 06/11] Update readmes and comments to match .NET Aspire's rebranding to Aspire --- README.md | 6 +++--- application/README.md | 4 ++-- developer-cli/Commands/InstallCommand.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c9ffad147a..d242873408 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ We recommend you keep the commit history, which serves as a great learning and t ## 2. Run the Aspire AppHost to spin up everything on localhost -Using .NET Aspire, docker images with SQL Server, Blob Storage emulator, and development mail server will be downloaded and started. No need install anything, or learn complicated commands. Simply run this command, and everything just works 🎉 +Using Aspire, docker images with SQL Server, Blob Storage emulator, and development mail server will be downloaded and started. No need install anything, or learn complicated commands. Simply run this command, and everything just works 🎉 ```bash cd application/AppHost @@ -205,7 +205,7 @@ PlatformPlatform is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) contain ├─ .github # Separate GitHub workflows for deploying Infrastructure and app ├─ .windsurf # Copy of .cursor for Windsurf AI editor (synchronized by CLI) ├─ application # Contains the application source code -│ ├─ AppHost # .NET Aspire project starting app and all dependencies in Docker +│ ├─ AppHost # Aspire project starting app and all dependencies in Docker │ ├─ AppGateway # Main entry point for the app using YARP as a reverse proxy │ ├─ account-management # Self-contained system with account sign-up, user management, etc. │ │ ├─ WebApp # React SPA frontend using TypeScript and React Aria Components @@ -238,7 +238,7 @@ PlatformPlatform is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) contain The backend is built using the most popular, mature, and commonly used technologies in the .NET ecosystem: - [.NET 9](https://dotnet.microsoft.com) and [C# 13](https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp) -- [.NET Aspire](https://aka.ms/dotnet-aspire) +- [Aspire](https://aka.ms/dotnet-aspire) - [YARP](https://microsoft.github.io/reverse-proxy) - [ASP.NET Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis) - [Entity Framework](https://learn.microsoft.com/en-us/ef) diff --git a/application/README.md b/application/README.md index c34eee9adb..12d7e491aa 100644 --- a/application/README.md +++ b/application/README.md @@ -12,11 +12,11 @@ While self-contained systems are somewhat similar to a microservice architecture There are also some shared projects: - `SharedKernel` - a foundation with generic functionalities and boilerplate code that are shared between self-contained systems. This ensures a secure and maintainable codebase. This not only guarantees a consistent architecture but also ensures that all self-contained systems are developed in a uniform manner, making it easy for developers to move between systems and focus on the business logic, rather than the infrastructure. In theory the shared kernel is maintained by the PlatformPlatform team, and there should be no reason for you to make changes to this project. - `AppGateway` - the single entry point for all self-contained systems, responsible for routing requests to the correct system using YARP reverse proxy as BFF (Backend for Frontend). It contains logic for refreshing access tokens, and it will eventually also handle tasks like, rate limiting, caching, etc. -- `AppHost` - only used for development, this is a .NET Aspire App Host that orchestrates starting all dependencies like SQL Server, Blob Storage, and Mail Server, and then starts all self-contained systems in a single operation. It’s a .NET alternative to Docker Compose. While Aspire can also be used for the deployment of infrastructure, this is not used in PlatformPlatform, as it’s not mature for enterprise-grade systems. If your self-contained system needs access to a different service, you can add it to the `AppHost` project. +- `AppHost` - only used for development, this is a Aspire App Host that orchestrates starting all dependencies like SQL Server, Blob Storage, and Mail Server, and then starts all self-contained systems in a single operation. It’s a .NET alternative to Docker Compose. While Aspire can also be used for the deployment of infrastructure, this is not used in PlatformPlatform, as it’s not mature for enterprise-grade systems. If your self-contained system needs access to a different service, you can add it to the `AppHost` project. ## Account Management -Account Management currently offers a skeleton of the essential parts of any multi-tenant SaaS solution, like allowing a business to sign up for new tenants, invite users, let users log in, etc. Eventually, it will contain features to showcase single sign-on (SSO), subscription management, etc. that are common to all SaaS solutions. As of now, it just showcases how to build a system using Vertical Slice Architecture with CQRS, DDD, ASP.NET Minimal API, SPA frontend, orchestrated with .NET Aspire. +Account Management currently offers a skeleton of the essential parts of any multi-tenant SaaS solution, like allowing a business to sign up for new tenants, invite users, let users log in, etc. Eventually, it will contain features to showcase single sign-on (SSO), subscription management, etc. that are common to all SaaS solutions. As of now, it just showcases how to build a system using Vertical Slice Architecture with CQRS, DDD, ASP.NET Minimal API, SPA frontend, orchestrated with Aspire. The [AccountManagement.slnf](/application/AccountManagement.slnf) solution file contains the Account Management system, which can be run and developed in isolation. This shows how simple it is to develop new features without all the boilerplate you often see in other projects. diff --git a/developer-cli/Commands/InstallCommand.cs b/developer-cli/Commands/InstallCommand.cs index 5ac2046d47..f8ad42c022 100644 --- a/developer-cli/Commands/InstallCommand.cs +++ b/developer-cli/Commands/InstallCommand.cs @@ -31,7 +31,7 @@ Just open the project in your IDE and review the code. [green]How does it work?[/] The CLI has several commands that you can run from anywhere on your machine. Each command is one C# class that can be customized to automate your own workflows. - Each command check for its prerequisites (e.g., Docker, Node, .NET Aspire, Azure CLI, etc.) + Each command check for its prerequisites (e.g., Docker, Node, Aspire, Azure CLI, etc.) To remove the alias, just run [green]{Configuration.AliasName} uninstall[/]. """; From abcb2e68835d1e1987aeb8a257bd61a7681cb4e5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 12:21:19 +0100 Subject: [PATCH 07/11] Update all backing fields to use the new C# 14 field syntax --- .../Core/Features/Users/Domain/User.cs | 6 ++---- .../Tests/EndpointBaseTest.cs | 8 ++------ .../back-office/Tests/EndpointBaseTest.cs | 8 ++------ .../ExecutionContext/HttpExecutionContext.cs | 19 ++++++++----------- 4 files changed, 14 insertions(+), 27 deletions(-) diff --git a/application/account-management/Core/Features/Users/Domain/User.cs b/application/account-management/Core/Features/Users/Domain/User.cs index 327b5f900b..a6d409b28a 100644 --- a/application/account-management/Core/Features/Users/Domain/User.cs +++ b/application/account-management/Core/Features/Users/Domain/User.cs @@ -5,8 +5,6 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Domain; public sealed class User : AggregateRoot, ITenantScopedEntity { - private string _email = string.Empty; - private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) : base(UserId.NewId()) { @@ -20,8 +18,8 @@ private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed public string Email { - get => _email; - private set => _email = value.ToLowerInvariant(); + get; + private set => field = value.ToLowerInvariant(); } public string? FirstName { get; private set; } diff --git a/application/account-management/Tests/EndpointBaseTest.cs b/application/account-management/Tests/EndpointBaseTest.cs index fb3995b577..c3260f773b 100644 --- a/application/account-management/Tests/EndpointBaseTest.cs +++ b/application/account-management/Tests/EndpointBaseTest.cs @@ -30,7 +30,6 @@ public abstract class EndpointBaseTest : IDisposable where TContext : protected readonly ServiceCollection Services; private readonly WebApplicationFactory _webApplicationFactory; protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; - private ServiceProvider? _provider; protected EndpointBaseTest() { @@ -85,11 +84,8 @@ protected EndpointBaseTest() Services.AddScoped(); - // Build the ServiceProvider - _provider = Services.BuildServiceProvider(); - // Make sure the database is created - using var serviceScope = Provider.CreateScope(); + using var serviceScope = Provider!.CreateScope(); serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); @@ -142,7 +138,7 @@ protected ServiceProvider Provider { // ServiceProvider is created on first access to allow Tests to configure services in the constructor // before the ServiceProvider is created - return _provider ??= Services.BuildServiceProvider(); + return field ??= Services.BuildServiceProvider(); } } diff --git a/application/back-office/Tests/EndpointBaseTest.cs b/application/back-office/Tests/EndpointBaseTest.cs index a9a29845dd..668d297f45 100644 --- a/application/back-office/Tests/EndpointBaseTest.cs +++ b/application/back-office/Tests/EndpointBaseTest.cs @@ -30,7 +30,6 @@ public abstract class EndpointBaseTest : IDisposable where TContext : protected readonly ServiceCollection Services; private readonly WebApplicationFactory _webApplicationFactory; protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; - private ServiceProvider? _provider; protected EndpointBaseTest() { @@ -85,11 +84,8 @@ protected EndpointBaseTest() Services.AddScoped(); - // Build the ServiceProvider - _provider = Services.BuildServiceProvider(); - // Make sure the database is created - using var serviceScope = Provider.CreateScope(); + using var serviceScope = Provider!.CreateScope(); serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); @@ -142,7 +138,7 @@ protected ServiceProvider Provider { // ServiceProvider is created on first access to allow Tests to configure services in the constructor // before the ServiceProvider is created - return _provider ??= Services.BuildServiceProvider(); + return field ??= Services.BuildServiceProvider(); } } diff --git a/application/shared-kernel/SharedKernel/ExecutionContext/HttpExecutionContext.cs b/application/shared-kernel/SharedKernel/ExecutionContext/HttpExecutionContext.cs index 9da1e60a68..78ea5c77b9 100644 --- a/application/shared-kernel/SharedKernel/ExecutionContext/HttpExecutionContext.cs +++ b/application/shared-kernel/SharedKernel/ExecutionContext/HttpExecutionContext.cs @@ -8,23 +8,20 @@ namespace PlatformPlatform.SharedKernel.ExecutionContext; public class HttpExecutionContext(IHttpContextAccessor httpContextAccessor) : IExecutionContext { - private IPAddress? _clientIpAddress; - private UserInfo? _userInfo; - public TenantId? TenantId => UserInfo.TenantId; public UserInfo UserInfo { get { - if (_userInfo is not null) + if (field is not null) { - return _userInfo; + return field; } var browserLocale = httpContextAccessor.HttpContext?.Features.Get()?.RequestCulture.Culture.Name; - return _userInfo = UserInfo.Create(httpContextAccessor.HttpContext?.User, browserLocale); + return field = UserInfo.Create(httpContextAccessor.HttpContext?.User, browserLocale); } } @@ -32,23 +29,23 @@ public IPAddress ClientIpAddress { get { - if (_clientIpAddress is not null) + if (field is not null) { - return _clientIpAddress; + return field; } if (httpContextAccessor.HttpContext == null) { - return _clientIpAddress = IPAddress.None; + return field = IPAddress.None; } var forwardedIps = httpContextAccessor.HttpContext.Request.Headers["X-Forwarded-For"].ToString().Split(','); if (IPAddress.TryParse(forwardedIps.LastOrDefault(), out var clientIpAddress)) { - return _clientIpAddress = clientIpAddress; + return field = clientIpAddress; } - return _clientIpAddress = IPAddress.None; + return field = IPAddress.None; } } } From 5255dd92d3e8a26f2db57e428d39db795168562f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 13:44:14 +0100 Subject: [PATCH 08/11] Change all extension methods to use C# 14's new extension members --- .../ApiAggregation/ApiAggregationEndpoints.cs | 37 +-- .../AppHost/ConfigurationExtensions.cs | 17 +- application/AppHost/SecretManagerHelper.cs | 40 +-- application/AppHost/SslCertificateManager.cs | 57 ++-- .../account-management/Core/Configuration.cs | 38 ++- .../Tests/FakerExtensions.cs | 33 +- application/back-office/Core/Configuration.cs | 16 +- .../ApiResults/ApiResultExtensions.cs | 18 +- .../SecurityTokenDescriptorExtensions.cs | 25 +- .../ApiDependencyConfiguration.cs | 282 ++++++++++-------- .../SharedDependencyConfiguration.cs | 209 ++++++------- .../SharedInfrastructureConfiguration.cs | 274 ++++++++--------- .../WorkerDependencyConfiguration.cs | 9 +- .../EntityFramework/ModelBuilderExtensions.cs | 109 +++---- .../SinglePageAppFallbackExtensions.cs | 123 ++++---- .../Tests/ApiAssertionExtensions.cs | 181 +++++------ .../Persistence/SqliteConnectionExtensions.cs | 197 ++++++------ 17 files changed, 860 insertions(+), 805 deletions(-) diff --git a/application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs b/application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs index 03ff17a253..cc1f25eb50 100644 --- a/application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs +++ b/application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs @@ -2,26 +2,29 @@ namespace PlatformPlatform.AppGateway.ApiAggregation; public static class Endpoints { - public static WebApplication ApiAggregationEndpoints(this WebApplication app) + extension(WebApplication app) { - app.MapGet("/swagger", context => - { - context.Response.Redirect("/openapi/v1"); - return Task.CompletedTask; - } - ); + public WebApplication ApiAggregationEndpoints() + { + app.MapGet("/swagger", context => + { + context.Response.Redirect("/openapi/v1"); + return Task.CompletedTask; + } + ); - app.MapGet("/openapi", context => - { - context.Response.Redirect("/openapi/v1"); - return Task.CompletedTask; - } - ); + app.MapGet("/openapi", context => + { + context.Response.Redirect("/openapi/v1"); + return Task.CompletedTask; + } + ); - app.MapGet("/openapi/v1.json", async (ApiAggregationService apiAggregationService) - => Results.Content(await apiAggregationService.GetAggregatedOpenApiJson(), "application/json") - ).CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5))); + app.MapGet("/openapi/v1.json", async (ApiAggregationService apiAggregationService) + => Results.Content(await apiAggregationService.GetAggregatedOpenApiJson(), "application/json") + ).CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5))); - return app; + return app; + } } } diff --git a/application/AppHost/ConfigurationExtensions.cs b/application/AppHost/ConfigurationExtensions.cs index 381e305f8a..7b119de7e3 100644 --- a/application/AppHost/ConfigurationExtensions.cs +++ b/application/AppHost/ConfigurationExtensions.cs @@ -2,15 +2,16 @@ namespace AppHost; public static class ConfigurationExtensions { - public static IResourceBuilder WithUrlConfiguration( - this IResourceBuilder builder, - string applicationBasePath) where TDestination : IResourceWithEnvironment + extension(IResourceBuilder builder) where TDestination : IResourceWithEnvironment { - var baseUrl = Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000"; - applicationBasePath = applicationBasePath.TrimEnd('/'); + public IResourceBuilder WithUrlConfiguration(string applicationBasePath) + { + var baseUrl = Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000"; + applicationBasePath = applicationBasePath.TrimEnd('/'); - return builder - .WithEnvironment("PUBLIC_URL", baseUrl) - .WithEnvironment("CDN_URL", baseUrl + applicationBasePath); + return builder + .WithEnvironment("PUBLIC_URL", baseUrl) + .WithEnvironment("CDN_URL", baseUrl + applicationBasePath); + } } } diff --git a/application/AppHost/SecretManagerHelper.cs b/application/AppHost/SecretManagerHelper.cs index 7f7222cafd..81c0ce358b 100644 --- a/application/AppHost/SecretManagerHelper.cs +++ b/application/AppHost/SecretManagerHelper.cs @@ -12,26 +12,6 @@ public static class SecretManagerHelper private static string UserSecretsId => Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; - public static IResourceBuilder CreateStablePassword( - this IDistributedApplicationBuilder builder, - string secretName - ) - { - var password = ConfigurationRoot[secretName]; - - if (string.IsNullOrEmpty(password)) - { - var passwordGenerator = new GenerateParameterDefault - { - MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3 - }; - password = passwordGenerator.GetDefaultValue(); - SaveSecrectToDotNetUserSecrets(secretName, password); - } - - return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true)); - } - public static void GenerateAuthenticationTokenSigningKey(string secretName) { if (string.IsNullOrEmpty(ConfigurationRoot[secretName])) @@ -56,4 +36,24 @@ private static void SaveSecrectToDotNetUserSecrets(string key, string value) using var process = Process.Start(startInfo)!; process.WaitForExit(); } + + extension(IDistributedApplicationBuilder builder) + { + public IResourceBuilder CreateStablePassword(string secretName) + { + var password = ConfigurationRoot[secretName]; + + if (string.IsNullOrEmpty(password)) + { + var passwordGenerator = new GenerateParameterDefault + { + MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3 + }; + password = passwordGenerator.GetDefaultValue(); + SaveSecrectToDotNetUserSecrets(secretName, password); + } + + return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true)); + } + } } diff --git a/application/AppHost/SslCertificateManager.cs b/application/AppHost/SslCertificateManager.cs index 7383c090d5..4c3acdc32d 100644 --- a/application/AppHost/SslCertificateManager.cs +++ b/application/AppHost/SslCertificateManager.cs @@ -11,33 +11,6 @@ public static class SslCertificateManager { private static string UserSecretsId => Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; - public static async Task CreateSslCertificateIfNotExists(this IDistributedApplicationBuilder builder, CancellationToken cancellationToken = default) - { - var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); - - const string certificatePasswordKey = "certificate-password"; - var certificatePassword = config[certificatePasswordKey] - ?? await builder.CreateStablePassword(certificatePasswordKey).Resource.GetValueAsync(cancellationToken) - ?? throw new InvalidOperationException("Failed to retrieve or create certificate password."); - - var certificateLocation = GetCertificateLocation("localhost"); - try - { - var certificate2 = X509CertificateLoader.LoadPkcs12FromFile(certificateLocation, certificatePassword); - if (certificate2.NotAfter < DateTime.UtcNow) - { - Console.WriteLine($"Certificate {certificateLocation} is expired. Creating a new certificate."); - CreateNewSelfSignedDeveloperCertificate(certificateLocation, certificatePassword); - } - } - catch (CryptographicException) - { - CreateNewSelfSignedDeveloperCertificate(certificateLocation, certificatePassword); - } - - return certificatePassword; - } - private static string GetCertificateLocation(string domain) { var userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -73,4 +46,34 @@ private static void CreateNewSelfSignedDeveloperCertificate(string certificateLo } )!.WaitForExit(); } + + extension(IDistributedApplicationBuilder builder) + { + public async Task CreateSslCertificateIfNotExists(CancellationToken cancellationToken = default) + { + var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); + + const string certificatePasswordKey = "certificate-password"; + var certificatePassword = config[certificatePasswordKey] + ?? await builder.CreateStablePassword(certificatePasswordKey).Resource.GetValueAsync(cancellationToken) + ?? throw new InvalidOperationException("Failed to retrieve or create certificate password."); + + var certificateLocation = GetCertificateLocation("localhost"); + try + { + var certificate2 = X509CertificateLoader.LoadPkcs12FromFile(certificateLocation, certificatePassword); + if (certificate2.NotAfter < DateTime.UtcNow) + { + Console.WriteLine($"Certificate {certificateLocation} is expired. Creating a new certificate."); + CreateNewSelfSignedDeveloperCertificate(certificateLocation, certificatePassword); + } + } + catch (CryptographicException) + { + CreateNewSelfSignedDeveloperCertificate(certificateLocation, certificatePassword); + } + + return certificatePassword; + } + } } diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index e68611df0b..f6c2d93d61 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -11,26 +11,32 @@ public static class Configuration { public static Assembly Assembly => Assembly.GetExecutingAssembly(); - public static IHostApplicationBuilder AddAccountManagementInfrastructure(this IHostApplicationBuilder builder) + extension(IHostApplicationBuilder builder) { - // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests - return builder - .AddSharedInfrastructure("account-management-database") - .AddNamedBlobStorages(("account-management-storage", "BLOB_STORAGE_URL")); + public IHostApplicationBuilder AddAccountManagementInfrastructure() + { + // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests + return builder + .AddSharedInfrastructure("account-management-database") + .AddNamedBlobStorages(("account-management-storage", "BLOB_STORAGE_URL")); + } } - public static IServiceCollection AddAccountManagementServices(this IServiceCollection services) + extension(IServiceCollection services) { - services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://gravatar.com/"); - client.Timeout = TimeSpan.FromSeconds(5); - } - ); + public IServiceCollection AddAccountManagementServices() + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://gravatar.com/"); + client.Timeout = TimeSpan.FromSeconds(5); + } + ); - return services - .AddSharedServices(Assembly) - .AddScoped() - .AddScoped(); + return services + .AddSharedServices(Assembly) + .AddScoped() + .AddScoped(); + } } } diff --git a/application/account-management/Tests/FakerExtensions.cs b/application/account-management/Tests/FakerExtensions.cs index 984114fe34..2e0f1877b5 100644 --- a/application/account-management/Tests/FakerExtensions.cs +++ b/application/account-management/Tests/FakerExtensions.cs @@ -5,24 +5,27 @@ namespace PlatformPlatform.AccountManagement.Tests; public static class FakerExtensions { - public static string TenantName(this Faker faker) + extension(Faker faker) { - return new string(faker.Company.CompanyName().Take(30).ToArray()); - } + public string TenantName() + { + return new string(faker.Company.CompanyName().Take(30).ToArray()); + } - public static string PhoneNumber(this Faker faker) - { - var random = new Random(); - return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; - } + public string PhoneNumber() + { + var random = new Random(); + return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; + } - public static string InvalidEmail(this Faker faker) - { - return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); - } + public string InvalidEmail() + { + return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); + } - public static long RandomId(this Faker faker) - { - return IdGenerator.NewId(); + public long RandomId() + { + return IdGenerator.NewId(); + } } } diff --git a/application/back-office/Core/Configuration.cs b/application/back-office/Core/Configuration.cs index 617b6e4469..7627d90ca7 100644 --- a/application/back-office/Core/Configuration.cs +++ b/application/back-office/Core/Configuration.cs @@ -9,14 +9,20 @@ public static class Configuration { public static Assembly Assembly => Assembly.GetExecutingAssembly(); - public static IHostApplicationBuilder AddBackOfficeInfrastructure(this IHostApplicationBuilder builder) + extension(IHostApplicationBuilder builder) { - // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests - return builder.AddSharedInfrastructure("back-office-database"); + public IHostApplicationBuilder AddBackOfficeInfrastructure() + { + // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests + return builder.AddSharedInfrastructure("back-office-database"); + } } - public static IServiceCollection AddBackOfficeServices(this IServiceCollection services) + extension(IServiceCollection services) { - return services.AddSharedServices(Assembly); + public IServiceCollection AddBackOfficeServices() + { + return services.AddSharedServices(Assembly); + } } } diff --git a/application/shared-kernel/SharedKernel/ApiResults/ApiResultExtensions.cs b/application/shared-kernel/SharedKernel/ApiResults/ApiResultExtensions.cs index aab9da5713..1f74523e92 100644 --- a/application/shared-kernel/SharedKernel/ApiResults/ApiResultExtensions.cs +++ b/application/shared-kernel/SharedKernel/ApiResults/ApiResultExtensions.cs @@ -5,17 +5,15 @@ namespace PlatformPlatform.SharedKernel.ApiResults; public static class ApiResultExtensions { - public static ApiResult AddResourceUri(this Result result, string routePrefix) + extension(Result result) { - return new ApiResult(result, routePrefix); - } - - public static ApiResult AddRefreshAuthenticationTokens(this Result result) - { - if (!result.IsSuccess) return new ApiResult(result); + public ApiResult AddRefreshAuthenticationTokens() + { + if (!result.IsSuccess) return new ApiResult(result); - return new ApiResult(result, httpHeaders: new Dictionary - { { AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, "true" } } - ); + return new ApiResult(result, httpHeaders: new Dictionary + { { AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, "true" } } + ); + } } } diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs index b7a936d692..4e0543f2fa 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs @@ -5,21 +5,18 @@ namespace PlatformPlatform.SharedKernel.Authentication.TokenGeneration; internal static class SecurityTokenDescriptorExtensions { - internal static string GenerateToken( - this SecurityTokenDescriptor tokenDescriptor, - DateTimeOffset expires, - string issuer, - string audience, - SigningCredentials signingCredentials - ) + extension(SecurityTokenDescriptor tokenDescriptor) { - tokenDescriptor.Expires = expires.UtcDateTime; - tokenDescriptor.Issuer = issuer; - tokenDescriptor.Audience = audience; - tokenDescriptor.SigningCredentials = signingCredentials; + internal string GenerateToken(DateTimeOffset expires, string issuer, string audience, SigningCredentials signingCredentials) + { + tokenDescriptor.Expires = expires.UtcDateTime; + tokenDescriptor.Issuer = issuer; + tokenDescriptor.Audience = audience; + tokenDescriptor.SigningCredentials = signingCredentials; - var tokenHandler = new JwtSecurityTokenHandler(); - var securityToken = tokenHandler.CreateToken(tokenDescriptor); - return tokenHandler.WriteToken(securityToken); + var tokenHandler = new JwtSecurityTokenHandler(); + var securityToken = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(securityToken); + } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs index 5ed543d9fe..c3cdfdaff6 100644 --- a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs @@ -23,177 +23,195 @@ public static class ApiDependencyConfiguration private static readonly string LocalhostUrl = Environment.GetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey)!; - public static WebApplicationBuilder AddApiInfrastructure(this WebApplicationBuilder builder) + extension(WebApplicationBuilder builder) { - if (builder.Environment.IsDevelopment()) + public WebApplicationBuilder AddApiInfrastructure() { - builder.Services.AddCors(options => options.AddPolicy( - LocalhostCorsPolicyName, - policyBuilder => { policyBuilder.WithOrigins(LocalhostUrl).AllowAnyMethod().AllowAnyHeader(); } - ) - ); - } - - builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; }); - return builder; - } - - public static WebApplicationBuilder AddDevelopmentPort(this WebApplicationBuilder builder, int port) - { - builder.WebHost.ConfigureKestrel((context, serverOptions) => + if (builder.Environment.IsDevelopment()) { - if (!context.HostingEnvironment.IsDevelopment()) return; - serverOptions.ConfigureEndpointDefaults(listenOptions => listenOptions.UseHttps()); - serverOptions.ListenLocalhost(port, listenOptions => listenOptions.UseHttps()); + builder.Services.AddCors(options => options.AddPolicy( + LocalhostCorsPolicyName, + policyBuilder => { policyBuilder.WithOrigins(LocalhostUrl).AllowAnyMethod().AllowAnyHeader(); } + ) + ); } - ); - return builder; - } - public static IServiceCollection AddApiServices(this IServiceCollection services, params Assembly[] assemblies) - { - return services - .AddApiExecutionContext() - .AddExceptionHandler() - .AddTransient() - .AddTransient() - .AddTransient() - .AddProblemDetails() - .AddEndpointsApiExplorer() - .AddApiEndpoints(assemblies) - .AddOpenApiConfiguration(assemblies) - .AddAuthConfiguration() - .AddCrossServiceDataProtection() - .AddAntiforgery(options => + builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; }); + return builder; + } + + public WebApplicationBuilder AddDevelopmentPort(int port) + { + builder.WebHost.ConfigureKestrel((context, serverOptions) => { - options.Cookie.Name = AuthenticationTokenHttpKeys.AntiforgeryTokenCookieName; - options.HeaderName = AuthenticationTokenHttpKeys.AntiforgeryTokenHttpHeaderKey; + if (!context.HostingEnvironment.IsDevelopment()) return; + serverOptions.ConfigureEndpointDefaults(listenOptions => listenOptions.UseHttps()); + serverOptions.ListenLocalhost(port, listenOptions => listenOptions.UseHttps()); } - ) - .AddHttpForwardHeaders(); - } - - private static IServiceCollection AddApiExecutionContext(this IServiceCollection services) - { - // Add the execution context service that will be used to make current user information available to the application - return services.AddScoped(); + ); + return builder; + } } - public static WebApplication UseApiServices(this WebApplication app) + extension(IServiceCollection services) { - if (app.Environment.IsDevelopment()) + public IServiceCollection AddApiServices(params Assembly[] assemblies) { - // Enable the developer exception page, which displays detailed information about exceptions that occur - app.UseDeveloperExceptionPage(); - app.UseCors(LocalhostCorsPolicyName); + return services + .AddApiExecutionContext() + .AddExceptionHandler() + .AddTransient() + .AddTransient() + .AddTransient() + .AddProblemDetails() + .AddEndpointsApiExplorer() + .AddApiEndpoints(assemblies) + .AddOpenApiConfiguration(assemblies) + .AddAuthConfiguration() + .AddCrossServiceDataProtection() + .AddAntiforgery(options => + { + options.Cookie.Name = AuthenticationTokenHttpKeys.AntiforgeryTokenCookieName; + options.HeaderName = AuthenticationTokenHttpKeys.AntiforgeryTokenHttpHeaderKey; + } + ) + .AddHttpForwardHeaders(); } - else + + private IServiceCollection AddApiExecutionContext() { - // Configure global exception handling for the production environment - app.UseExceptionHandler(_ => { }); + // Add the execution context service that will be used to make current user information available to the application + return services.AddScoped(); } - - app - .UseForwardedHeaders() - .UseAuthentication() // Must be above TelemetryContextMiddleware to ensure authentication happens first - .UseAuthorization() - .UseAntiforgery() - .UseMiddleware() - .UseMiddleware() // It must be above ModelBindingExceptionHandlerMiddleware to ensure that model binding problems are annotated correctly - .UseMiddleware() // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto. Should run before other middleware - .UseOpenApi(options => options.Path = "/openapi/v1.json"); // Adds the OpenAPI generator that uses the ASP. NET Core API Explorer - - return app.UseApiEndpoints(); } - private static IServiceCollection AddApiEndpoints(this IServiceCollection services, params Assembly[] assemblies) + extension(WebApplication app) { - return services - .Scan(scan => scan - .FromAssemblies(assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray()) - .AddClasses(classes => classes.AssignableTo(), false) - .AsImplementedInterfaces() - .WithScopedLifetime() - ); + public WebApplication UseApiServices() + { + if (app.Environment.IsDevelopment()) + { + // Enable the developer exception page, which displays detailed information about exceptions that occur + app.UseDeveloperExceptionPage(); + app.UseCors(LocalhostCorsPolicyName); + } + else + { + // Configure global exception handling for the production environment + app.UseExceptionHandler(_ => { }); + } + + app + .UseForwardedHeaders() + .UseAuthentication() // Must be above TelemetryContextMiddleware to ensure authentication happens first + .UseAuthorization() + .UseAntiforgery() + .UseMiddleware() + .UseMiddleware() // It must be above ModelBindingExceptionHandlerMiddleware to ensure that model binding problems are annotated correctly + .UseMiddleware() // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto. Should run before other middleware + .UseOpenApi(options => options.Path = "/openapi/v1.json"); // Adds the OpenAPI generator that uses the ASP. NET Core API Explorer + + return app.UseApiEndpoints(); + } } - private static WebApplication UseApiEndpoints(this WebApplication app) + extension(IServiceCollection services) { - // Manually create all endpoint classes to call the MapEndpoints containing the mappings - using var scope = app.Services.CreateScope(); - var endpointServices = scope.ServiceProvider.GetServices(); - foreach (var endpoint in endpointServices) + private IServiceCollection AddApiEndpoints(params Assembly[] assemblies) { - endpoint.MapEndpoints(app); + return services + .Scan(scan => scan + .FromAssemblies(assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray()) + .AddClasses(classes => classes.AssignableTo(), false) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); } - - return app; } - private static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services, params Assembly[] assemblies) + extension(WebApplication app) { - return services.AddOpenApiDocument((settings, _) => + private WebApplication UseApiEndpoints() + { + // Manually create all endpoint classes to call the MapEndpoints containing the mappings + using var scope = app.Services.CreateScope(); + var endpointServices = scope.ServiceProvider.GetServices(); + foreach (var endpoint in endpointServices) { - settings.DocumentName = "v1"; - settings.Title = "PlatformPlatform API"; - settings.Version = "v1"; - - var options = (SystemTextJsonSchemaGeneratorSettings)settings.SchemaSettings; - options.SerializerOptions = SharedDependencyConfiguration.DefaultJsonSerializerOptions; - settings.DocumentProcessors.Add(new StronglyTypedDocumentProcessor(assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray())); + endpoint.MapEndpoints(app); } - ); + + return app; + } } - private static IServiceCollection AddAuthConfiguration(this IServiceCollection services) + extension(IServiceCollection services) { - // Add Authentication and Authorization services - services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - } - ) - .AddJwtBearer(o => + private IServiceCollection AddOpenApiConfiguration(params Assembly[] assemblies) + { + return services.AddOpenApiDocument((settings, _) => { - var tokenSigningService = SharedDependencyConfiguration.GetTokenSigningService(); - o.TokenValidationParameters = tokenSigningService.GetTokenValidationParameters( - validateLifetime: true, - clockSkew: TimeSpan.FromSeconds(5) // In Azure, we don't need any clock skew, but this must be a higher value than the AppGateway - ); + settings.DocumentName = "v1"; + settings.Title = "PlatformPlatform API"; + settings.Version = "v1"; + + var options = (SystemTextJsonSchemaGeneratorSettings)settings.SchemaSettings; + options.SerializerOptions = SharedDependencyConfiguration.DefaultJsonSerializerOptions; + settings.DocumentProcessors.Add(new StronglyTypedDocumentProcessor(assemblies.Concat([Assembly.GetExecutingAssembly()]).ToArray())); } ); + } - return services.AddAuthorization(); - } - - private static IServiceCollection AddCrossServiceDataProtection(this IServiceCollection services) - { - // Configure shared data protection to ensure encrypted data can be shared across all self-contained systems - var dataProtection = services.AddDataProtection(); - - if (!SharedInfrastructureConfiguration.IsRunningInAzure) + private IServiceCollection AddAuthConfiguration() { - // Set a common application name for all self-contained systems for local development (handled automatically by Azure Container Apps Environment) - dataProtection.SetApplicationName("PlatformPlatform"); + // Add Authentication and Authorization services + services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + } + ) + .AddJwtBearer(o => + { + var tokenSigningService = SharedDependencyConfiguration.GetTokenSigningService(); + o.TokenValidationParameters = tokenSigningService.GetTokenValidationParameters( + validateLifetime: true, + clockSkew: TimeSpan.FromSeconds(5) // In Azure, we don't need any clock skew, but this must be a higher value than the AppGateway + ); + } + ); + + return services.AddAuthorization(); } - return services; - } + private IServiceCollection AddCrossServiceDataProtection() + { + // Configure shared data protection to ensure encrypted data can be shared across all self-contained systems + var dataProtection = services.AddDataProtection(); - public static IServiceCollection AddHttpForwardHeaders(this IServiceCollection services) - { - // Ensure correct client IP addresses are set for requests - // This is required when running behind a reverse proxy like YARP or Azure Container Apps - return services.Configure(options => + if (!SharedInfrastructureConfiguration.IsRunningInAzure) { - // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownIPNetworks.Clear(); - options.KnownProxies.Clear(); + // Set a common application name for all self-contained systems for local development (handled automatically by Azure Container Apps Environment) + dataProtection.SetApplicationName("PlatformPlatform"); } - ); + + return services; + } + + public IServiceCollection AddHttpForwardHeaders() + { + // Ensure correct client IP addresses are set for requests + // This is required when running behind a reverse proxy like YARP or Azure Container Apps + return services.Configure(options => + { + // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + } + ); + } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs index 2b8bfa50ed..c0efef427d 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs @@ -29,28 +29,6 @@ public static class SharedDependencyConfiguration PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public static IServiceCollection AddSharedServices(this IServiceCollection services, params Assembly[] assemblies) - where T : DbContext - { - // Even though the HttpContextAccessor is not available in Worker Services, it is still registered here because - // workers register the same CommandHandlers as the API, which may require the HttpContext. - // Consider making a generic IRequestContextProvider that can return the HttpContext only if it is available. - services.AddHttpContextAccessor(); - - return services - .AddServiceDiscovery() - .AddSingleton(GetTokenSigningService()) - .AddSingleton(Settings.Current) - .AddAuthentication() - .AddDefaultJsonSerializerOptions() - .AddPersistenceHelpers() - .AddDefaultHealthChecks() - .AddEmailClient() - .AddMediatRPipelineBehaviors() - .RegisterMediatRRequest(assemblies) - .RegisterRepositories(assemblies); - } - public static ITokenSigningClient GetTokenSigningService() { if (SharedInfrastructureConfiguration.IsRunningInAzure) @@ -72,100 +50,125 @@ public static ITokenSigningClient GetTokenSigningService() return new DevelopmentTokenSigningClient(); } - private static IServiceCollection AddAuthentication(this IServiceCollection services) + extension(IServiceCollection services) { - return services - .AddScoped, PasswordHasher>() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(); - } + public IServiceCollection AddSharedServices(params Assembly[] assemblies) + where T : DbContext + { + // Even though the HttpContextAccessor is not available in Worker Services, it is still registered here because + // workers register the same CommandHandlers as the API, which may require the HttpContext. + // Consider making a generic IRequestContextProvider that can return the HttpContext only if it is available. + services.AddHttpContextAccessor(); + + return services + .AddServiceDiscovery() + .AddSingleton(GetTokenSigningService()) + .AddSingleton(Settings.Current) + .AddAuthentication() + .AddDefaultJsonSerializerOptions() + .AddPersistenceHelpers() + .AddDefaultHealthChecks() + .AddEmailClient() + .AddMediatRPipelineBehaviors() + .RegisterMediatRRequest(assemblies) + .RegisterRepositories(assemblies); + } - private static IServiceCollection AddDefaultJsonSerializerOptions(this IServiceCollection services) - { - return services.Configure(options => - { - // Copy the default options from the DefaultJsonSerializerOptions to enforce consistency in serialization. - foreach (var jsonConverter in DefaultJsonSerializerOptions.Converters) - { - options.SerializerOptions.Converters.Add(jsonConverter); - } + private IServiceCollection AddAuthentication() + { + return services + .AddScoped, PasswordHasher>() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + } - options.SerializerOptions.PropertyNamingPolicy = DefaultJsonSerializerOptions.PropertyNamingPolicy; - } - ); - } + private IServiceCollection AddDefaultJsonSerializerOptions() + { + return services.Configure(options => + { + // Copy the default options from the DefaultJsonSerializerOptions to enforce consistency in serialization. + foreach (var jsonConverter in DefaultJsonSerializerOptions.Converters) + { + options.SerializerOptions.Converters.Add(jsonConverter); + } - private static IServiceCollection AddPersistenceHelpers(this IServiceCollection services) where T : DbContext - { - return services - .AddScoped(provider => new UnitOfWork(provider.GetRequiredService())) - .AddScoped(provider => - new DomainEventCollector(provider.GetRequiredService()) + options.SerializerOptions.PropertyNamingPolicy = DefaultJsonSerializerOptions.PropertyNamingPolicy; + } ); - } - - private static IServiceCollection AddDefaultHealthChecks(this IServiceCollection services) - { - // Add a default liveness check to ensure the app is responsive - services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - return services; - } + } - private static IServiceCollection AddEmailClient(this IServiceCollection services) - { - if (SharedInfrastructureConfiguration.IsRunningInAzure) + private IServiceCollection AddPersistenceHelpers() where T : DbContext { - var keyVaultUri = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_URL")!); - services - .AddSingleton(_ => new SecretClient(keyVaultUri, SharedInfrastructureConfiguration.DefaultAzureCredential)) - .AddTransient(); + return services + .AddScoped(provider => new UnitOfWork(provider.GetRequiredService())) + .AddScoped(provider => + new DomainEventCollector(provider.GetRequiredService()) + ); } - else + + private IServiceCollection AddDefaultHealthChecks() { - services.AddTransient(); + // Add a default liveness check to ensure the app is responsive + services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + return services; } - return services; - } + private IServiceCollection AddEmailClient() + { + if (SharedInfrastructureConfiguration.IsRunningInAzure) + { + var keyVaultUri = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_URL")!); + services + .AddSingleton(_ => new SecretClient(keyVaultUri, SharedInfrastructureConfiguration.DefaultAzureCredential)) + .AddTransient(); + } + else + { + services.AddTransient(); + } - private static IServiceCollection AddMediatRPipelineBehaviors(this IServiceCollection services) - { - // Order is important! First all Pre behaviors run, then the command is handled, and finally all Post behaviors run. - // So Validation → Command → PublishDomainEvents → UnitOfWork → PublishTelemetryEvents. - services - .AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)) // Pre - .AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishTelemetryEventsPipelineBehavior<,>)) // Post - .AddTransient(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkPipelineBehavior<,>)) // Post - .AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishDomainEventsPipelineBehavior<,>)); // Post - - return services - .AddScoped() - .AddScoped(); - } + return services; + } - private static IServiceCollection RegisterMediatRRequest(this IServiceCollection services, params Assembly[] assemblies) - { - return services - .AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(assemblies)) - .AddValidatorsFromAssemblies(assemblies); - } + private IServiceCollection AddMediatRPipelineBehaviors() + { + // Order is important! First all Pre behaviors run, then the command is handled, and finally all Post behaviors run. + // So Validation → Command → PublishDomainEvents → UnitOfWork → PublishTelemetryEvents. + services + .AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)) // Pre + .AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishTelemetryEventsPipelineBehavior<,>)) // Post + .AddTransient(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkPipelineBehavior<,>)) // Post + .AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishDomainEventsPipelineBehavior<,>)); // Post + + return services + .AddScoped() + .AddScoped(); + } - private static IServiceCollection RegisterRepositories(this IServiceCollection services, params Assembly[] assemblies) - { - // Scrutor will scan the assembly for all classes that implement the IRepository - // and register them as a service in the container. - return services - .Scan(scan => scan - .FromAssemblies(assemblies) - .AddClasses(classes => classes.Where(type => - type.BaseType is { IsGenericType: true } && - type.BaseType.GetGenericTypeDefinition() == typeof(RepositoryBase<,>) - ), false - ) - .AsImplementedInterfaces() - .WithScopedLifetime() - ); + private IServiceCollection RegisterMediatRRequest(params Assembly[] assemblies) + { + return services + .AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(assemblies)) + .AddValidatorsFromAssemblies(assemblies); + } + + private IServiceCollection RegisterRepositories(params Assembly[] assemblies) + { + // Scrutor will scan the assembly for all classes that implement the IRepository + // and register them as a service in the container. + return services + .Scan(scan => scan + .FromAssemblies(assemblies) + .AddClasses(classes => classes.Where(type => + type.BaseType is { IsGenericType: true } && + type.BaseType.GetGenericTypeDefinition() == typeof(RepositoryBase<,>) + ), false + ) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index 3237474378..6a5db831e9 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -23,27 +23,6 @@ public static class SharedInfrastructureConfiguration public static DefaultAzureCredential DefaultAzureCredential => GetDefaultAzureCredential(); - public static IHostApplicationBuilder AddSharedInfrastructure(this IHostApplicationBuilder builder, string connectionName) - where T : DbContext - { - builder - .ConfigureDatabaseContext(connectionName) - .AddDefaultBlobStorage() - .AddConfigureOpenTelemetry() - .AddOpenTelemetryExporters(); - - builder.Services - .AddApplicationInsightsTelemetry() - .ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); // Turn on resilience by default - http.AddServiceDiscovery(); // Turn on service discovery by default - } - ); - - return builder; - } - private static DefaultAzureCredential GetDefaultAzureCredential() { // Hack: Remove trailing whitespace from the environment variable, added in Bicep to workaround issue #157. @@ -52,160 +31,187 @@ private static DefaultAzureCredential GetDefaultAzureCredential() return new DefaultAzureCredential(credentialOptions); } - private static IHostApplicationBuilder ConfigureDatabaseContext(this IHostApplicationBuilder builder, string connectionName) - where T : DbContext + extension(IHostApplicationBuilder builder) { - var connectionString = IsRunningInAzure - ? Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING") - : builder.Configuration.GetConnectionString(connectionName); + public IHostApplicationBuilder AddSharedInfrastructure(string connectionName) + where T : DbContext + { + builder + .ConfigureDatabaseContext(connectionName) + .AddDefaultBlobStorage() + .AddConfigureOpenTelemetry() + .AddOpenTelemetryExporters(); - builder.Services.AddDbContext(options => - options.UseSqlServer(connectionString, sqlOptions => - sqlOptions.UseCompatibilityLevel(150) // SQL Server 2019 compatibility to avoid native JSON type - ) - ); + builder.Services + .AddApplicationInsightsTelemetry() + .ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); // Turn on resilience by default + http.AddServiceDiscovery(); // Turn on service discovery by default + } + ); - return builder; + return builder; + } } - private static IHostApplicationBuilder AddDefaultBlobStorage(this IHostApplicationBuilder builder) + extension(IHostApplicationBuilder builder) { - // Register the default storage account for BlobStorage - if (IsRunningInAzure) + private IHostApplicationBuilder ConfigureDatabaseContext(string connectionName) + where T : DbContext { - var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential)) + var connectionString = IsRunningInAzure + ? Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING") + : builder.Configuration.GetConnectionString(connectionName); + + builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, sqlOptions => + sqlOptions.UseCompatibilityLevel(150) // SQL Server 2019 compatibility to avoid native JSON type + ) ); - } - else - { - var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(connectionString))); - } - return builder; - } + return builder; + } - /// - /// Register different storage accounts for BlobStorage using .NET Keyed services, when a service needs to access - /// multiple storage accounts - /// - public static IHostApplicationBuilder AddNamedBlobStorages( - this IHostApplicationBuilder builder, - params (string ConnectionName, string EnvironmentVariable)[] connections - ) - { - if (IsRunningInAzure) + private IHostApplicationBuilder AddDefaultBlobStorage() { - foreach (var connection in connections) + // Register the default storage account for BlobStorage + if (IsRunningInAzure) { - var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection.EnvironmentVariable)!); - builder.Services.AddKeyedSingleton(connection.ConnectionName, - (_, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential)) + var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); + builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential)) ); } - } - else - { - var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - foreach (var connection in connections) + else { - builder.Services.AddKeyedSingleton(connection.ConnectionName, - (_, _) => new BlobStorageClient(new BlobServiceClient(connectionString)) - ); + var connectionString = builder.Configuration.GetConnectionString("blob-storage"); + builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(connectionString))); } - } - return builder; - } + return builder; + } - private static IHostApplicationBuilder AddConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - builder.Services.Configure(options => + /// + /// Register different storage accounts for BlobStorage using .NET Keyed services, when a service needs to access + /// multiple storage accounts + /// + public IHostApplicationBuilder AddNamedBlobStorages(params (string ConnectionName, string EnvironmentVariable)[] connections) + { + if (IsRunningInAzure) + { + foreach (var connection in connections) + { + var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection.EnvironmentVariable)!); + builder.Services.AddKeyedSingleton(connection.ConnectionName, + (_, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential)) + ); + } + } + else { - // ReSharper disable once RedundantLambdaParameterType - options.Filter = (HttpContext httpContext) => + var connectionString = builder.Configuration.GetConnectionString("blob-storage"); + foreach (var connection in connections) { - var requestPath = httpContext.Request.Path.ToString(); + builder.Services.AddKeyedSingleton(connection.ConnectionName, + (_, _) => new BlobStorageClient(new BlobServiceClient(connectionString)) + ); + } + } - if (EndpointTelemetryFilter.ExcludedPaths.Any(excludePath => requestPath.StartsWith(excludePath))) - { - return false; - } + return builder; + } - if (EndpointTelemetryFilter.ExcludedFileExtensions.Any(excludeExtension => requestPath.EndsWith(excludeExtension))) + private IHostApplicationBuilder AddConfigureOpenTelemetry() + { + builder.Services.Configure(options => + { + // ReSharper disable once RedundantLambdaParameterType + options.Filter = (HttpContext httpContext) => { - return false; - } + var requestPath = httpContext.Request.Path.ToString(); - return true; - }; - } - ); + if (EndpointTelemetryFilter.ExcludedPaths.Any(excludePath => requestPath.StartsWith(excludePath))) + { + return false; + } - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - } - ); + if (EndpointTelemetryFilter.ExcludedFileExtensions.Any(excludeExtension => requestPath.EndsWith(excludeExtension))) + { + return false; + } - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); + return true; + }; } - ) - .WithTracing(tracing => - { - // We want to view all traces in development - if (builder.Environment.IsDevelopment()) tracing.SetSampler(new AlwaysOnSampler()); + ); - tracing.AddAspNetCoreInstrumentation().AddGrpcClientInstrumentation().AddHttpClientInstrumentation(); + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; } ); - return builder; - } + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + } + ) + .WithTracing(tracing => + { + // We want to view all traces in development + if (builder.Environment.IsDevelopment()) tracing.SetSampler(new AlwaysOnSampler()); - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + tracing.AddAspNetCoreInstrumentation().AddGrpcClientInstrumentation().AddHttpClientInstrumentation(); + } + ); - if (useOtlpExporter) - { - builder.Services - .Configure(logging => logging.AddOtlpExporter()) - .ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()) - .ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + return builder; } - builder.Services.AddOpenTelemetry().UseAzureMonitor(options => + private IHostApplicationBuilder AddOpenTelemetryExporters() + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) { - options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"; + builder.Services + .Configure(logging => logging.AddOtlpExporter()) + .ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()) + .ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } - ); - return builder; + builder.Services.AddOpenTelemetry().UseAzureMonitor(options => + { + options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"; + } + ); + + return builder; + } } - private static IServiceCollection AddApplicationInsightsTelemetry(this IServiceCollection services) + extension(IServiceCollection services) { - var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions + private IServiceCollection AddApplicationInsightsTelemetry() { - EnableQuickPulseMetricStream = false, - EnableRequestTrackingTelemetryModule = false, - EnableDependencyTrackingTelemetryModule = false, - RequestCollectionOptions = { TrackExceptions = false } - }; - - return services - .AddApplicationInsightsTelemetry(applicationInsightsServiceOptions) - .AddApplicationInsightsTelemetryProcessor() - .AddScoped() - .AddSingleton(); + var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions + { + EnableQuickPulseMetricStream = false, + EnableRequestTrackingTelemetryModule = false, + EnableDependencyTrackingTelemetryModule = false, + RequestCollectionOptions = { TrackExceptions = false } + }; + + return services + .AddApplicationInsightsTelemetry(applicationInsightsServiceOptions) + .AddApplicationInsightsTelemetryProcessor() + .AddScoped() + .AddSingleton(); + } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/WorkerDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/WorkerDependencyConfiguration.cs index 5cb716437b..12ee023283 100644 --- a/application/shared-kernel/SharedKernel/Configuration/WorkerDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/WorkerDependencyConfiguration.cs @@ -5,9 +5,12 @@ namespace PlatformPlatform.SharedKernel.Configuration; public static class WorkerDependencyConfiguration { - public static IServiceCollection AddWorkerServices(this IServiceCollection services) + extension(IServiceCollection services) { - // Add the execution context service that will be used to make current user information available to the application - return services.AddScoped(); + public IServiceCollection AddWorkerServices() + { + // Add the execution context service that will be used to make current user information available to the application + return services.AddScoped(); + } } } diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs index 299c38e164..18730801f7 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs @@ -8,72 +8,73 @@ namespace PlatformPlatform.SharedKernel.EntityFramework; public static class ModelBuilderExtensions { - /// - /// This method is used to tell Entity Framework how to map a strongly typed ID to a SQL column using the - /// underlying type of the strongly-typed ID. - /// - public static void MapStronglyTypedLongId(this EntityTypeBuilder builder, Expression> expression) - where T : class where TId : StronglyTypedLongId + extension(EntityTypeBuilder builder) where T : class { - builder - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } + /// + /// This method is used to tell Entity Framework how to map a strongly typed ID to a SQL column using the + /// underlying type of the strongly-typed ID. + /// + public void MapStronglyTypedLongId(Expression> expression) where TId : StronglyTypedLongId + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } - public static void MapStronglyTypedUuid(this EntityTypeBuilder builder, Expression> expression) - where T : class where TId : StronglyTypedUlid - { - builder - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } + public void MapStronglyTypedUuid(Expression> expression) where TId : StronglyTypedUlid + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } - public static void MapStronglyTypedId(this EntityTypeBuilder builder, Expression> expression) - where T : class - where TValue : IComparable - where TId : StronglyTypedId - { - builder - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } + public void MapStronglyTypedId(Expression> expression) + where TValue : IComparable + where TId : StronglyTypedId + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } - public static void MapStronglyTypedNullableId( - this EntityTypeBuilder builder, - Expression> idExpression - ) - where T : class - where TValue : class, IComparable - where TId : StronglyTypedId - { - var nullConstant = Expression.Constant(null, typeof(TValue)); - var idParameter = Expression.Parameter(typeof(TId), "id"); - var idValueProperty = Expression.Property(idParameter, "Value"); - var idCoalesceExpression = - Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); + public void MapStronglyTypedNullableId( + Expression> idExpression + ) + where TValue : class, IComparable + where TId : StronglyTypedId + { + var nullConstant = Expression.Constant(null, typeof(TValue)); + var idParameter = Expression.Parameter(typeof(TId), "id"); + var idValueProperty = Expression.Property(idParameter, "Value"); + var idCoalesceExpression = + Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); - builder - .Property(idExpression) - .HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId); + builder + .Property(idExpression) + .HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId); + } } - /// - /// This method is used to tell Entity Framework to store all enum properties as strings in the database. - /// - public static ModelBuilder UseStringForEnums(this ModelBuilder modelBuilder) + extension(ModelBuilder modelBuilder) { - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + /// + /// This method is used to tell Entity Framework to store all enum properties as strings in the database. + /// + public ModelBuilder UseStringForEnums() { - foreach (var property in entityType.GetProperties()) + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { - if (!property.ClrType.IsEnum) continue; + foreach (var property in entityType.GetProperties()) + { + if (!property.ClrType.IsEnum) continue; - var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType); - var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; - property.SetValueConverter(converterInstance); + var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType); + var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; + property.SetValueConverter(converterInstance); + } } - } - return modelBuilder; + return modelBuilder; + } } } diff --git a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs index bea4f36dfc..75bbe585e7 100644 --- a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs +++ b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs @@ -16,66 +16,6 @@ namespace PlatformPlatform.SharedKernel.SinglePageApp; public static class SinglePageAppFallbackExtensions { - public static IServiceCollection AddSinglePageAppFallback( - this IServiceCollection services, - params (string Key, string Value)[] environmentVariables - ) - { - return services.AddSingleton(serviceProvider => - { - var environment = serviceProvider.GetRequiredService(); - return new SinglePageAppConfiguration(environment.IsDevelopment(), environmentVariables); - } - ); - } - - public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication app) - { - app.Map("/remoteEntry.js", (HttpContext context, SinglePageAppConfiguration singlePageAppConfiguration) => - { - var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); - - SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript", nonce); - - var javaScript = singlePageAppConfiguration.GetRemoteEntryJs(); - return context.Response.WriteAsync(javaScript); - } - ); - - app.MapFallback(( - HttpContext context, - IExecutionContext executionContext, - IAntiforgery antiforgery, - SinglePageAppConfiguration singlePageAppConfiguration - ) => - { - if (context.Request.Path.Value?.Contains("/api/", StringComparison.OrdinalIgnoreCase) == true || - context.Request.Path.Value?.Contains("/internal-api/", StringComparison.OrdinalIgnoreCase) == true) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - context.Response.ContentType = "text/plain"; - return context.Response.WriteAsync("404 Not Found"); - } - - var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); - - SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8", nonce); - - var antiforgeryHttpHeaderToken = GenerateAntiforgeryTokens(antiforgery, context); - - var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken, nonce); - - return context.Response.WriteAsync(html); - } - ); - - Directory.CreateDirectory(SinglePageAppConfiguration.BuildRootPath); - - return app - .UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(SinglePageAppConfiguration.BuildRootPath) }) - .UseRequestLocalization(SinglePageAppConfiguration.SupportedLocalizations); - } - private static void SetResponseHttpHeaders( SinglePageAppConfiguration singlePageAppConfiguration, IHeaderDictionary responseHeaders, @@ -149,4 +89,67 @@ string nonce return html; } + + extension(IServiceCollection services) + { + public IServiceCollection AddSinglePageAppFallback(params (string Key, string Value)[] environmentVariables) + { + return services.AddSingleton(serviceProvider => + { + var environment = serviceProvider.GetRequiredService(); + return new SinglePageAppConfiguration(environment.IsDevelopment(), environmentVariables); + } + ); + } + } + + extension(WebApplication app) + { + public IApplicationBuilder UseSinglePageAppFallback() + { + app.Map("/remoteEntry.js", (HttpContext context, SinglePageAppConfiguration singlePageAppConfiguration) => + { + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + + SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript", nonce); + + var javaScript = singlePageAppConfiguration.GetRemoteEntryJs(); + return context.Response.WriteAsync(javaScript); + } + ); + + app.MapFallback(( + HttpContext context, + IExecutionContext executionContext, + IAntiforgery antiforgery, + SinglePageAppConfiguration singlePageAppConfiguration + ) => + { + if (context.Request.Path.Value?.Contains("/api/", StringComparison.OrdinalIgnoreCase) == true || + context.Request.Path.Value?.Contains("/internal-api/", StringComparison.OrdinalIgnoreCase) == true) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.ContentType = "text/plain"; + return context.Response.WriteAsync("404 Not Found"); + } + + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + + SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8", nonce); + + var antiforgeryHttpHeaderToken = GenerateAntiforgeryTokens(antiforgery, context); + + var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken, nonce); + + return context.Response.WriteAsync(html); + } + ); + + Directory.CreateDirectory(SinglePageAppConfiguration.BuildRootPath); + + return app + .UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(SinglePageAppConfiguration.BuildRootPath) }) + .UseRequestLocalization(SinglePageAppConfiguration.SupportedLocalizations); + } + } } diff --git a/application/shared-kernel/Tests/ApiAssertionExtensions.cs b/application/shared-kernel/Tests/ApiAssertionExtensions.cs index 1bf247b00d..052a15ad92 100644 --- a/application/shared-kernel/Tests/ApiAssertionExtensions.cs +++ b/application/shared-kernel/Tests/ApiAssertionExtensions.cs @@ -44,118 +44,119 @@ public static class ApiAssertionExtensions HttpStatusCode.HttpVersionNotSupported ]; - public static void ShouldBeSuccessfulGetRequest(this HttpResponseMessage response) + extension(HttpResponseMessage response) { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); - response.Headers.Location.Should().BeNull(); - } - - public static async Task ShouldBeSuccessfulPostRequest( - this HttpResponseMessage response, - string? exact = null, - string? startsWith = null, - bool hasLocation = true - ) - { - var responseBody = await response.Content.ReadAsStringAsync(); - responseBody.Should().BeEmpty(); - - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - - if (hasLocation) - { - response.Headers.Location.Should().NotBeNull(); - } - else + public void ShouldBeSuccessfulGetRequest() { + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); response.Headers.Location.Should().BeNull(); } - if (exact is not null) + public async Task ShouldBeSuccessfulPostRequest( + string? exact = null, + string? startsWith = null, + bool hasLocation = true + ) { - response.Headers.Location!.ToString().Should().Be(exact); + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody.Should().BeEmpty(); + + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + + if (hasLocation) + { + response.Headers.Location.Should().NotBeNull(); + } + else + { + response.Headers.Location.Should().BeNull(); + } + + if (exact is not null) + { + response.Headers.Location!.ToString().Should().Be(exact); + } + + if (startsWith is not null) + { + response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); + } } - if (startsWith is not null) + public void ShouldHaveEmptyHeaderAndLocationOnSuccess() { - response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); - } - } - - public static void ShouldHaveEmptyHeaderAndLocationOnSuccess(this HttpResponseMessage response) - { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - response.Headers.Location.Should().BeNull(); - } - - public static Task ShouldHaveErrorStatusCode(this HttpResponseMessage response, HttpStatusCode statusCode, IEnumerable expectedErrors) - { - return ShouldHaveErrorStatusCode(response, statusCode, null, expectedErrors); - } - - public static async Task ShouldHaveErrorStatusCode( - this HttpResponseMessage response, - HttpStatusCode statusCode, - string? expectedDetail, - IEnumerable? expectedErrors = null, - bool hasTraceId = false - ) - { - response.StatusCode.Should().Be(statusCode); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); - - var problemDetails = await DeserializeProblemDetails(response); - - problemDetails.Should().NotBeNull(); - problemDetails.Status.Should().Be((int)statusCode); - if (StatusCodesWithLink.Contains(statusCode)) - { - problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + response.Headers.Location.Should().BeNull(); } - problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); - - if (expectedDetail is not null) + public Task ShouldHaveErrorStatusCode(HttpStatusCode statusCode, IEnumerable expectedErrors) { - problemDetails.Detail.Should().Be(expectedDetail); + return ShouldHaveErrorStatusCode(response, statusCode, null, expectedErrors); } - if (expectedErrors is not null) + public async Task ShouldHaveErrorStatusCode( + HttpStatusCode statusCode, + string? expectedDetail, + IEnumerable? expectedErrors = null, + bool hasTraceId = false + ) { - var actualErrorsJson = (JsonElement)problemDetails.Extensions["errors"]!; - var actualErrors = JsonSerializer.Deserialize>( - actualErrorsJson.GetRawText(), SharedDependencyConfiguration.DefaultJsonSerializerOptions - ); - - var expectedErrorsDictionary = expectedErrors.GroupBy(e => e.PropertyName) - .ToDictionary( - g => JsonNamingPolicy.CamelCase.ConvertName(g.Key), - g => g.Select(e => e.Message).ToArray() + response.StatusCode.Should().Be(statusCode); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + + var problemDetails = await DeserializeProblemDetails(response); + + problemDetails.Should().NotBeNull(); + problemDetails.Status.Should().Be((int)statusCode); + if (StatusCodesWithLink.Contains(statusCode)) + { + problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); + } + + problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); + + if (expectedDetail is not null) + { + problemDetails.Detail.Should().Be(expectedDetail); + } + + if (expectedErrors is not null) + { + var actualErrorsJson = (JsonElement)problemDetails.Extensions["errors"]!; + var actualErrors = JsonSerializer.Deserialize>( + actualErrorsJson.GetRawText(), SharedDependencyConfiguration.DefaultJsonSerializerOptions ); - actualErrors.Should().BeEquivalentTo(expectedErrorsDictionary); - } + var expectedErrorsDictionary = expectedErrors.GroupBy(e => e.PropertyName) + .ToDictionary( + g => JsonNamingPolicy.CamelCase.ConvertName(g.Key), + g => g.Select(e => e.Message).ToArray() + ); - if (hasTraceId) - { - problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); + actualErrors.Should().BeEquivalentTo(expectedErrorsDictionary); + } + + if (hasTraceId) + { + problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); + } } - } - public static async Task DeserializeResponse(this HttpResponseMessage response) - { - var responseStream = await response.Content.ReadAsStreamAsync(); + public async Task DeserializeResponse() + { + var responseStream = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(responseStream, SharedDependencyConfiguration.DefaultJsonSerializerOptions); - } + return await JsonSerializer.DeserializeAsync(responseStream, SharedDependencyConfiguration.DefaultJsonSerializerOptions); + } - private static async Task DeserializeProblemDetails(this HttpResponseMessage response) - { - var content = await response.Content.ReadAsStringAsync(); + private async Task DeserializeProblemDetails() + { + var content = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(content, SharedDependencyConfiguration.DefaultJsonSerializerOptions); + return JsonSerializer.Deserialize(content, SharedDependencyConfiguration.DefaultJsonSerializerOptions); + } } } diff --git a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs index 5ddf15f2d4..a885906da8 100644 --- a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs +++ b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs @@ -4,121 +4,124 @@ namespace PlatformPlatform.SharedKernel.Tests.Persistence; public static class SqliteConnectionExtensions { - [Obsolete("Use ExecuteScalar instead")] - public static long ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) + extension(SqliteConnection connection) { - return connection.ExecuteScalar(sql, parameters); - } - - public static T ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) - { - using var command = new SqliteCommand(sql, connection); - - foreach (var parameter in parameters) + [Obsolete("Use ExecuteScalar instead")] + public long ExecuteScalar(string sql, params object?[] parameters) { - foreach (var property in parameter?.GetType().GetProperties() ?? []) - { - command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); - } + return connection.ExecuteScalar(sql, parameters); } - var result = command.ExecuteScalar(); - return result is DBNull ? default! : (T)result!; - } + public T ExecuteScalar(string sql, params object?[] parameters) + { + using var command = new SqliteCommand(sql, connection); - public static bool RowExists(this SqliteConnection connection, string tableName, string id) - { - object?[] parameters = [new { id }]; - return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; - } + foreach (var parameter in parameters) + { + foreach (var property in parameter?.GetType().GetProperties() ?? []) + { + command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); + } + } - public static bool RowExists(this SqliteConnection connection, string tableName, long id) - { - object?[] parameters = [new { id }]; - return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; - } + var result = command.ExecuteScalar(); + return result is DBNull ? default! : (T)result!; + } - public static void Insert(this SqliteConnection connection, string tableName, (string, object?)[] columns) - { - var columnsNames = string.Join(", ", columns.Select(c => c.Item1)); - var columnsValues = string.Join(", ", columns.Select(c => "@" + c.Item1)); - var insertCommandText = $"INSERT INTO {tableName} ({columnsNames}) VALUES ({columnsValues})"; - using var command = new SqliteCommand(insertCommandText, connection); - foreach (var column in columns) + public bool RowExists(string tableName, string id) { - var valueType = column.Item2?.GetType(); - - var sqliteType = valueType switch - { - not null when valueType == typeof(int) => SqliteType.Integer, - not null when valueType == typeof(long) => SqliteType.Integer, - not null when valueType == typeof(bool) => SqliteType.Integer, - not null when valueType == typeof(double) => SqliteType.Real, - not null when valueType == typeof(float) => SqliteType.Real, - not null when valueType == typeof(decimal) => SqliteType.Real, - not null when valueType == typeof(byte[]) => SqliteType.Blob, - not null when valueType == typeof(string) => SqliteType.Text, - not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text - not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text - null => SqliteType.Text, // Handle null values by setting SqliteType to Text - _ => SqliteType.Text // Default to Text if the type is unknown - }; - var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; - command.Parameters.Add(parameter); + object?[] parameters = [new { id }]; + return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; } - command.ExecuteNonQuery(); - } + public bool RowExists(string tableName, long id) + { + object?[] parameters = [new { id }]; + return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; + } - public static void Update(this SqliteConnection connection, string tableName, string idColumnName, object idValue, (string, object?)[] columns) - { - var setClause = string.Join(", ", columns.Select(c => $"{c.Item1} = @{c.Item1}")); - var updateCommandText = $"UPDATE {tableName} SET {setClause} WHERE {idColumnName} = @{idColumnName}"; + public void Insert(string tableName, (string, object?)[] columns) + { + var columnsNames = string.Join(", ", columns.Select(c => c.Item1)); + var columnsValues = string.Join(", ", columns.Select(c => "@" + c.Item1)); + var insertCommandText = $"INSERT INTO {tableName} ({columnsNames}) VALUES ({columnsValues})"; + using var command = new SqliteCommand(insertCommandText, connection); + foreach (var column in columns) + { + var valueType = column.Item2?.GetType(); + + var sqliteType = valueType switch + { + not null when valueType == typeof(int) => SqliteType.Integer, + not null when valueType == typeof(long) => SqliteType.Integer, + not null when valueType == typeof(bool) => SqliteType.Integer, + not null when valueType == typeof(double) => SqliteType.Real, + not null when valueType == typeof(float) => SqliteType.Real, + not null when valueType == typeof(decimal) => SqliteType.Real, + not null when valueType == typeof(byte[]) => SqliteType.Blob, + not null when valueType == typeof(string) => SqliteType.Text, + not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text + not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text + null => SqliteType.Text, // Handle null values by setting SqliteType to Text + _ => SqliteType.Text // Default to Text if the type is unknown + }; + var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; + command.Parameters.Add(parameter); + } - using var command = new SqliteCommand(updateCommandText, connection); + command.ExecuteNonQuery(); + } - // Add ID parameter - var idValueType = idValue.GetType(); - var idSqliteType = idValueType switch + public void Update(string tableName, string idColumnName, object idValue, (string, object?)[] columns) { - not null when idValueType == typeof(int) => SqliteType.Integer, - not null when idValueType == typeof(long) => SqliteType.Integer, - _ => SqliteType.Text - }; - var idParameter = new SqliteParameter($"@{idColumnName}", idSqliteType) { Value = idValue }; - command.Parameters.Add(idParameter); - - // Add column parameters - foreach (var column in columns) - { - var valueType = column.Item2?.GetType(); + var setClause = string.Join(", ", columns.Select(c => $"{c.Item1} = @{c.Item1}")); + var updateCommandText = $"UPDATE {tableName} SET {setClause} WHERE {idColumnName} = @{idColumnName}"; + + using var command = new SqliteCommand(updateCommandText, connection); - var sqliteType = valueType switch + // Add ID parameter + var idValueType = idValue.GetType(); + var idSqliteType = idValueType switch { - not null when valueType == typeof(int) => SqliteType.Integer, - not null when valueType == typeof(long) => SqliteType.Integer, - not null when valueType == typeof(bool) => SqliteType.Integer, - not null when valueType == typeof(double) => SqliteType.Real, - not null when valueType == typeof(float) => SqliteType.Real, - not null when valueType == typeof(decimal) => SqliteType.Real, - not null when valueType == typeof(byte[]) => SqliteType.Blob, - not null when valueType == typeof(string) => SqliteType.Text, - not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text - not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text - null => SqliteType.Text, // Handle null values by setting SqliteType to Text - _ => SqliteType.Text // Default to Text if the type is unknown + not null when idValueType == typeof(int) => SqliteType.Integer, + not null when idValueType == typeof(long) => SqliteType.Integer, + _ => SqliteType.Text }; - var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; - command.Parameters.Add(parameter); - } + var idParameter = new SqliteParameter($"@{idColumnName}", idSqliteType) { Value = idValue }; + command.Parameters.Add(idParameter); - command.ExecuteNonQuery(); - } + // Add column parameters + foreach (var column in columns) + { + var valueType = column.Item2?.GetType(); + + var sqliteType = valueType switch + { + not null when valueType == typeof(int) => SqliteType.Integer, + not null when valueType == typeof(long) => SqliteType.Integer, + not null when valueType == typeof(bool) => SqliteType.Integer, + not null when valueType == typeof(double) => SqliteType.Real, + not null when valueType == typeof(float) => SqliteType.Real, + not null when valueType == typeof(decimal) => SqliteType.Real, + not null when valueType == typeof(byte[]) => SqliteType.Blob, + not null when valueType == typeof(string) => SqliteType.Text, + not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text + not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text + null => SqliteType.Text, // Handle null values by setting SqliteType to Text + _ => SqliteType.Text // Default to Text if the type is unknown + }; + var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; + command.Parameters.Add(parameter); + } - public static void Delete(this SqliteConnection connection, string tableName, string id) - { - using var command = new SqliteCommand($"DELETE FROM {tableName} WHERE Id = @id", connection); - command.Parameters.AddWithValue("@id", id); - command.ExecuteNonQuery(); + command.ExecuteNonQuery(); + } + + public void Delete(string tableName, string id) + { + using var command = new SqliteCommand($"DELETE FROM {tableName} WHERE Id = @id", connection); + command.Parameters.AddWithValue("@id", id); + command.ExecuteNonQuery(); + } } } From 9581c090d6615b5de55817f756286a55c6e6f136 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 14:28:54 +0100 Subject: [PATCH 09/11] Remove params keyword from extension methods to fix CS8620 and Rider warnings --- application/AppGateway/Program.cs | 2 +- application/account-management/Api/Program.cs | 2 +- application/account-management/Core/Configuration.cs | 4 ++-- .../Tests/Authentication/CompleteLoginTests.cs | 12 ++++++------ .../Tests/Authentication/SwitchTenantTests.cs | 12 ++++++------ .../Tests/Signups/CompleteSignupTests.cs | 2 +- .../Tests/Users/DeclineInvitationTests.cs | 6 +++--- .../Tests/Users/InviteUserTests.cs | 2 +- application/back-office/Api/Program.cs | 2 +- application/back-office/Core/Configuration.cs | 2 +- .../Configuration/ApiDependencyConfiguration.cs | 6 +++--- .../Configuration/SharedDependencyConfiguration.cs | 6 +++--- .../SharedInfrastructureConfiguration.cs | 8 ++++---- .../SinglePageApp/SinglePageAppConfiguration.cs | 2 +- .../Tests/Persistence/SqliteConnectionExtensions.cs | 10 +++++----- 15 files changed, 39 insertions(+), 39 deletions(-) diff --git a/application/AppGateway/Program.cs b/application/AppGateway/Program.cs index 608f8b381e..8fc8f4ef62 100644 --- a/application/AppGateway/Program.cs +++ b/application/AppGateway/Program.cs @@ -36,7 +36,7 @@ ); } -builder.AddNamedBlobStorages(("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL")); +builder.AddNamedBlobStorages([("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL")]); builder.WebHost.UseKestrel(option => option.AddServerHeader = false); diff --git a/application/account-management/Api/Program.cs b/application/account-management/Api/Program.cs index 1c040f3f03..a08e318fa6 100644 --- a/application/account-management/Api/Program.cs +++ b/application/account-management/Api/Program.cs @@ -12,7 +12,7 @@ // Configure dependency injection services like Repositories, MediatR, Pipelines, FluentValidation validators, etc. builder.Services - .AddApiServices(Assembly.GetExecutingAssembly(), Configuration.Assembly) + .AddApiServices([Assembly.GetExecutingAssembly(), Configuration.Assembly]) .AddAccountManagementServices() .AddSinglePageAppFallback(); diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index f6c2d93d61..cd1ef71247 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -18,7 +18,7 @@ public IHostApplicationBuilder AddAccountManagementInfrastructure() // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests return builder .AddSharedInfrastructure("account-management-database") - .AddNamedBlobStorages(("account-management-storage", "BLOB_STORAGE_URL")); + .AddNamedBlobStorages([("account-management-storage", "BLOB_STORAGE_URL")]); } } @@ -34,7 +34,7 @@ public IServiceCollection AddAccountManagementServices() ); return services - .AddSharedServices(Assembly) + .AddSharedServices([Assembly]) .AddScoped() .AddScoped(); } diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index d417d5e471..01a9cecf9f 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -36,7 +36,7 @@ public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens() // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); var updatedLoginCount = Connection.ExecuteScalar( - "SELECT COUNT(*) FROM Logins WHERE Id = @id AND Completed = 1", new { id = loginId.ToString() } + "SELECT COUNT(*) FROM Logins WHERE Id = @id AND Completed = 1", [new { id = loginId.ToString() }] ); updatedLoginCount.Should().Be(1); @@ -82,9 +82,9 @@ public async Task CompleteLogin_WhenInvalidOneTimePassword_ShouldReturnBadReques await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "The code is wrong or no longer valid."); // Verify retry count increment and event collection - var loginCompleted = Connection.ExecuteScalar("SELECT Completed FROM Logins WHERE Id = @id", new { id = loginId.ToString() }); + var loginCompleted = Connection.ExecuteScalar("SELECT Completed FROM Logins WHERE Id = @id", [new { id = loginId.ToString() }]); loginCompleted.Should().Be(0); - var updatedRetryCount = Connection.ExecuteScalar("SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", new { id = emailConfirmationId.ToString() }); + var updatedRetryCount = Connection.ExecuteScalar("SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", [new { id = emailConfirmationId.ToString() }]); updatedRetryCount.Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); @@ -130,11 +130,11 @@ public async Task CompleteLogin_WhenRetryCountExceeded_ShouldReturnForbidden() // Verify retry count increment and event collection var loginCompleted = Connection.ExecuteScalar( - "SELECT Completed FROM Logins WHERE Id = @id", new { id = loginId.ToString() } + "SELECT Completed FROM Logins WHERE Id = @id", [new { id = loginId.ToString() }] ); loginCompleted.Should().Be(0); var updatedRetryCount = Connection.ExecuteScalar( - "SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", new { id = emailConfirmationId.ToString() } + "SELECT RetryCount FROM EmailConfirmations WHERE Id = @id", [new { id = emailConfirmationId.ToString() }] ); updatedRetryCount.Should().Be(4); @@ -215,7 +215,7 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc // Assert Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 1", - new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() } + [new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() }] ).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(3); diff --git a/application/account-management/Tests/Authentication/SwitchTenantTests.cs b/application/account-management/Tests/Authentication/SwitchTenantTests.cs index 42ae817f43..522a9ff844 100644 --- a/application/account-management/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account-management/Tests/Authentication/SwitchTenantTests.cs @@ -189,7 +189,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() // Verify that the user's email is now confirmed var emailConfirmed = Connection.ExecuteScalar( "SELECT EmailConfirmed FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); emailConfirmed.Should().Be(1); // SQLite stores boolean as 0/1 } @@ -257,23 +257,23 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() // Verify profile data was copied var firstName = Connection.ExecuteScalar( "SELECT FirstName FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); var lastName = Connection.ExecuteScalar( "SELECT LastName FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); var title = Connection.ExecuteScalar( "SELECT Title FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); var locale = Connection.ExecuteScalar( "SELECT Locale FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); var emailConfirmed = Connection.ExecuteScalar( "SELECT EmailConfirmed FROM Users WHERE Id = @Id", - new { Id = user2Id.ToString() } + [new { Id = user2Id.ToString() }] ); firstName.Should().Be(currentFirstName); diff --git a/application/account-management/Tests/Signups/CompleteSignupTests.cs b/application/account-management/Tests/Signups/CompleteSignupTests.cs index 2ba0fe3384..0758e29245 100644 --- a/application/account-management/Tests/Signups/CompleteSignupTests.cs +++ b/application/account-management/Tests/Signups/CompleteSignupTests.cs @@ -40,7 +40,7 @@ public async Task CompleteSignup_WhenValid_ShouldCreateTenantAndOwnerUser() // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); - Connection.ExecuteScalar("SELECT COUNT(*) FROM Users WHERE Email = @email", new { email = email.ToLower() }).Should().Be(1); + Connection.ExecuteScalar("SELECT COUNT(*) FROM Users WHERE Email = @email", [new { email = email.ToLower() }]).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(4); TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SignupStarted"); diff --git a/application/account-management/Tests/Users/DeclineInvitationTests.cs b/application/account-management/Tests/Users/DeclineInvitationTests.cs index e106fb877a..5178c7a4d3 100644 --- a/application/account-management/Tests/Users/DeclineInvitationTests.cs +++ b/application/account-management/Tests/Users/DeclineInvitationTests.cs @@ -58,7 +58,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE Id = @userId", - new { userId = userId.ToString() } + [new { userId = userId.ToString() }] ).Should().Be(0); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); @@ -154,11 +154,11 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE Id = @userId", - new { userId = userId2.ToString() } + [new { userId = userId2.ToString() }] ).Should().Be(0); Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE Id = @userId", - new { userId = userId3.ToString() } + [new { userId = userId3.ToString() }] ).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); diff --git a/application/account-management/Tests/Users/InviteUserTests.cs b/application/account-management/Tests/Users/InviteUserTests.cs index 8dca4e7b59..1c439f94a1 100644 --- a/application/account-management/Tests/Users/InviteUserTests.cs +++ b/application/account-management/Tests/Users/InviteUserTests.cs @@ -51,7 +51,7 @@ public async Task InviteUser_WhenTenantHasName_ShouldCreateUserAndUseTenantNameI // Verify user was created Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 0", - new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() } + [new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() }] ).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); diff --git a/application/back-office/Api/Program.cs b/application/back-office/Api/Program.cs index 1edce9f5bd..19f1eb7e28 100644 --- a/application/back-office/Api/Program.cs +++ b/application/back-office/Api/Program.cs @@ -12,7 +12,7 @@ // Configure dependency injection services like Repositories, MediatR, Pipelines, FluentValidation validators, etc. builder.Services - .AddApiServices(Assembly.GetExecutingAssembly(), Configuration.Assembly) + .AddApiServices([Assembly.GetExecutingAssembly(), Configuration.Assembly]) .AddBackOfficeServices() .AddSinglePageAppFallback(); diff --git a/application/back-office/Core/Configuration.cs b/application/back-office/Core/Configuration.cs index 7627d90ca7..e7c94566d4 100644 --- a/application/back-office/Core/Configuration.cs +++ b/application/back-office/Core/Configuration.cs @@ -22,7 +22,7 @@ public IHostApplicationBuilder AddBackOfficeInfrastructure() { public IServiceCollection AddBackOfficeServices() { - return services.AddSharedServices(Assembly); + return services.AddSharedServices([Assembly]); } } } diff --git a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs index c3cdfdaff6..2350638345 100644 --- a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs @@ -55,7 +55,7 @@ public WebApplicationBuilder AddDevelopmentPort(int port) extension(IServiceCollection services) { - public IServiceCollection AddApiServices(params Assembly[] assemblies) + public IServiceCollection AddApiServices(Assembly[] assemblies) { return services .AddApiExecutionContext() @@ -117,7 +117,7 @@ public WebApplication UseApiServices() extension(IServiceCollection services) { - private IServiceCollection AddApiEndpoints(params Assembly[] assemblies) + private IServiceCollection AddApiEndpoints(Assembly[] assemblies) { return services .Scan(scan => scan @@ -147,7 +147,7 @@ private WebApplication UseApiEndpoints() extension(IServiceCollection services) { - private IServiceCollection AddOpenApiConfiguration(params Assembly[] assemblies) + private IServiceCollection AddOpenApiConfiguration(Assembly[] assemblies) { return services.AddOpenApiDocument((settings, _) => { diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs index c0efef427d..d2a3c3616e 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs @@ -52,7 +52,7 @@ public static ITokenSigningClient GetTokenSigningService() extension(IServiceCollection services) { - public IServiceCollection AddSharedServices(params Assembly[] assemblies) + public IServiceCollection AddSharedServices(Assembly[] assemblies) where T : DbContext { // Even though the HttpContextAccessor is not available in Worker Services, it is still registered here because @@ -147,14 +147,14 @@ private IServiceCollection AddMediatRPipelineBehaviors() .AddScoped(); } - private IServiceCollection RegisterMediatRRequest(params Assembly[] assemblies) + private IServiceCollection RegisterMediatRRequest(Assembly[] assemblies) { return services .AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(assemblies)) .AddValidatorsFromAssemblies(assemblies); } - private IServiceCollection RegisterRepositories(params Assembly[] assemblies) + private IServiceCollection RegisterRepositories(Assembly[] assemblies) { // Scrutor will scan the assembly for all classes that implement the IRepository // and register them as a service in the container. diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index 6a5db831e9..461e89cf15 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -95,14 +95,14 @@ private IHostApplicationBuilder AddDefaultBlobStorage() /// Register different storage accounts for BlobStorage using .NET Keyed services, when a service needs to access /// multiple storage accounts /// - public IHostApplicationBuilder AddNamedBlobStorages(params (string ConnectionName, string EnvironmentVariable)[] connections) + public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, string EnvironmentVariable)?[] connections) { if (IsRunningInAzure) { foreach (var connection in connections) { - var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection.EnvironmentVariable)!); - builder.Services.AddKeyedSingleton(connection.ConnectionName, + var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection!.Value.EnvironmentVariable)!); + builder.Services.AddKeyedSingleton(connection.Value.ConnectionName, (_, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential)) ); } @@ -112,7 +112,7 @@ public IHostApplicationBuilder AddNamedBlobStorages(params (string ConnectionNam var connectionString = builder.Configuration.GetConnectionString("blob-storage"); foreach (var connection in connections) { - builder.Services.AddKeyedSingleton(connection.ConnectionName, + builder.Services.AddKeyedSingleton(connection!.Value.ConnectionName, (_, _) => new BlobStorageClient(new BlobServiceClient(connectionString)) ); } diff --git a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs index 8e80e39250..efa7182e0c 100644 --- a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs +++ b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs @@ -30,7 +30,7 @@ public class SinglePageAppConfiguration private string? _htmlTemplate; private string? _remoteEntryJsContent; - public SinglePageAppConfiguration(bool isDevelopment, params (string Key, string Value)[] environmentVariables) + public SinglePageAppConfiguration(bool isDevelopment, (string Key, string Value)[] environmentVariables) { // Environment variables are empty when generating EF Core migrations PublicUrl = Environment.GetEnvironmentVariable(PublicUrlKey) ?? string.Empty; diff --git a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs index a885906da8..d33e168203 100644 --- a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs +++ b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs @@ -7,18 +7,18 @@ public static class SqliteConnectionExtensions extension(SqliteConnection connection) { [Obsolete("Use ExecuteScalar instead")] - public long ExecuteScalar(string sql, params object?[] parameters) + public long ExecuteScalar(string sql, object[] parameters) { return connection.ExecuteScalar(sql, parameters); } - public T ExecuteScalar(string sql, params object?[] parameters) + public T ExecuteScalar(string sql, object[] parameters) { using var command = new SqliteCommand(sql, connection); foreach (var parameter in parameters) { - foreach (var property in parameter?.GetType().GetProperties() ?? []) + foreach (var property in parameter.GetType().GetProperties()) { command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); } @@ -30,13 +30,13 @@ public T ExecuteScalar(string sql, params object?[] parameters) public bool RowExists(string tableName, string id) { - object?[] parameters = [new { id }]; + object[] parameters = [new { id }]; return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; } public bool RowExists(string tableName, long id) { - object?[] parameters = [new { id }]; + object[] parameters = [new { id }]; return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", parameters) == 1; } From 174878ac9dda7e0893fe0e96b82c74c6b22fb49d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 21:30:45 +0100 Subject: [PATCH 10/11] Upgrade to System.CommandLine 2.0.0 and remove NamingConventionBinder --- developer-cli/Commands/BuildCommand.cs | 17 ++-- developer-cli/Commands/CheckCommand.cs | 40 ++++++--- developer-cli/Commands/CoAuthorCommand.cs | 3 +- .../ConfigureContinuousDeploymentsCommand.cs | 7 +- developer-cli/Commands/CoverageCommand.cs | 7 +- developer-cli/Commands/End2EndCommand.cs | 90 +++++++++++++------ developer-cli/Commands/FormatCommand.cs | 19 ++-- developer-cli/Commands/InspectCommand.cs | 21 +++-- developer-cli/Commands/InstallCommand.cs | 3 +- .../PullPlatformPlatformChangesCommand.cs | 26 ++++-- .../SyncWindsurfAiRulesAndWorkflowsCommand.cs | 3 +- developer-cli/Commands/TestCommand.cs | 13 ++- developer-cli/Commands/TranslateCommand.cs | 14 ++- developer-cli/Commands/UninstallCommand.cs | 3 +- .../Commands/UpdatePackagesCommand.cs | 26 ++++-- developer-cli/Commands/WatchCommand.cs | 27 ++++-- developer-cli/DeveloperCli.csproj | 3 +- developer-cli/Program.cs | 16 ++-- 18 files changed, 225 insertions(+), 113 deletions(-) diff --git a/developer-cli/Commands/BuildCommand.cs b/developer-cli/Commands/BuildCommand.cs index 5478b0cb63..0265b8feb1 100644 --- a/developer-cli/Commands/BuildCommand.cs +++ b/developer-cli/Commands/BuildCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -11,13 +10,19 @@ public class BuildCommand : Command { public BuildCommand() : base("build", "Builds a self-contained system") { - Handler = CommandHandler.Create(Execute); + var backendOption = new Option("--backend", "-b") { Description = "Run only backend build" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend build" }; + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to build (only used for backend builds)" }; - AddOption(new Option(["--backend", "-b"], "Run only backend build")); - AddOption(new Option(["--frontend", "-f"], "Run only frontend build")); - AddOption(new Option(["", "--solution-name", "-s"], "The name of the self-contained system to build (only used for backend builds)")); + Options.Add(backendOption); + Options.Add(frontendOption); + Options.Add(solutionNameOption); - Handler = CommandHandler.Create(Execute); + this.SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(solutionNameOption) + )); } private static void Execute(bool backend, bool frontend, string? solutionName) diff --git a/developer-cli/Commands/CheckCommand.cs b/developer-cli/Commands/CheckCommand.cs index fb69724e05..7db3f300ea 100644 --- a/developer-cli/Commands/CheckCommand.cs +++ b/developer-cli/Commands/CheckCommand.cs @@ -1,5 +1,5 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; +using System.CommandLine.Invocation; using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -11,13 +11,25 @@ public class CheckCommand : Command { public CheckCommand() : base("check", "Performs all checks including build, test, format, and inspect for backend and frontend code") { - AddOption(new Option(["--backend", "-b"], "Run only backend checks")); - AddOption(new Option(["--frontend", "-f"], "Run only frontend checks")); - AddOption(new Option(["", "--solution-name", "-s"], "The name of the self-contained system to check (only used for backend checks)")); - AddOption(new Option(["--skip-format"], () => false, "Skip the backend format step which can be time consuming")); - AddOption(new Option(["--skip-inspect"], () => false, "Skip the backend inspection step which can be time consuming")); + var backendOption = new Option("--backend", "-b") { Description = "Run only backend checks" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend checks" }; + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to check (only used for backend checks)" }; + var skipFormatOption = new Option("--skip-format") { Description = "Skip the backend format step which can be time consuming" }; + var skipInspectOption = new Option("--skip-inspect") { Description = "Skip the backend inspection step which can be time consuming" }; - Handler = CommandHandler.Create(Execute); + Options.Add(backendOption); + Options.Add(frontendOption); + Options.Add(solutionNameOption); + Options.Add(skipFormatOption); + Options.Add(skipInspectOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(solutionNameOption), + parseResult.GetValue(skipFormatOption), + parseResult.GetValue(skipInspectOption) + )); } private static void Execute(bool backend, bool frontend, string? solutionName, bool skipFormat, bool skipInspect) @@ -67,24 +79,24 @@ private static void RunBackendChecks(string? solutionName, bool skipFormat, bool { string[] solutionArgs = solutionName is not null ? ["--solution-name", solutionName] : []; - new BuildCommand().InvokeAsync([.. solutionArgs, "--backend"]); - new TestCommand().InvokeAsync([.. solutionArgs, "--no-build"]); + new BuildCommand().Parse([.. solutionArgs, "--backend"]).Invoke(); + new TestCommand().Parse([.. solutionArgs, "--no-build"]).Invoke(); if (!skipFormat) { - new FormatCommand().InvokeAsync([.. solutionArgs, "--backend"]); + new FormatCommand().Parse([.. solutionArgs, "--backend"]).Invoke(); } if (!skipInspect) { - new InspectCommand().InvokeAsync([.. solutionArgs, "--backend", "--no-build"]); + new InspectCommand().Parse([.. solutionArgs, "--backend", "--no-build"]).Invoke(); } } private static void RunFrontendChecks() { - new BuildCommand().InvokeAsync(["--frontend"]); - new FormatCommand().InvokeAsync(["--frontend"]); - new InspectCommand().InvokeAsync(["--frontend"]); + new BuildCommand().Parse(["--frontend"]).Invoke(); + new FormatCommand().Parse(["--frontend"]).Invoke(); + new InspectCommand().Parse(["--frontend"]).Invoke(); } } diff --git a/developer-cli/Commands/CoAuthorCommand.cs b/developer-cli/Commands/CoAuthorCommand.cs index 822b5605c9..c86568b67f 100644 --- a/developer-cli/Commands/CoAuthorCommand.cs +++ b/developer-cli/Commands/CoAuthorCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; using Spectre.Console; @@ -12,7 +11,7 @@ public sealed class CoAuthorCommand : Command public CoAuthorCommand() : base("coauthor", "Amends the current commit and adds you as a co-author") { - Handler = CommandHandler.Create(Execute); + this.SetAction(_ => Execute()); } private static void Execute() diff --git a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs index 1bf3639f4f..086a6073f4 100644 --- a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs +++ b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Diagnostics; using System.Reflection; using System.Text; @@ -26,9 +25,11 @@ public ConfigureContinuousDeploymentsCommand() : base( "Set up trust between Azure and GitHub for passwordless deployments using OpenID Connect" ) { - AddOption(new Option(["--verbose-logging"], "Print Azure and GitHub CLI commands and output")); + var verboseLoggingOption = new Option("--verbose-logging") { Description = "Print Azure and GitHub CLI commands and output" }; - Handler = CommandHandler.Create(Execute); + Options.Add(verboseLoggingOption); + + this.SetAction(parseResult => Execute(parseResult.GetValue(verboseLoggingOption))); } private void Execute(bool verboseLogging = false) diff --git a/developer-cli/Commands/CoverageCommand.cs b/developer-cli/Commands/CoverageCommand.cs index f5d8e98532..e42e7d5a68 100644 --- a/developer-cli/Commands/CoverageCommand.cs +++ b/developer-cli/Commands/CoverageCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; using Spectre.Console; @@ -10,9 +9,11 @@ public class CoverageCommand : Command { public CoverageCommand() : base("coverage", "Run JetBrains Code Coverage") { - AddOption(new Option(["", "--solution-name", "-s"], "The name of the self-contained system to build")); + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to build" }; - Handler = CommandHandler.Create(Execute); + Options.Add(solutionNameOption); + + this.SetAction(parseResult => Execute(parseResult.GetValue(solutionNameOption))); } private static void Execute(string? solutionName) diff --git a/developer-cli/Commands/End2EndCommand.cs b/developer-cli/Commands/End2EndCommand.cs index c5e3de58a6..33951b5d72 100644 --- a/developer-cli/Commands/End2EndCommand.cs +++ b/developer-cli/Commands/End2EndCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -16,30 +15,71 @@ public class End2EndCommand : Command public End2EndCommand() : base("e2e", "Run end-to-end tests using Playwright") { - // Add argument for search term or test file patterns - AddArgument(new Argument("search-terms", () => [], "Search terms for test filtering (e.g., 'user management', '@smoke', 'smoke', 'comprehensive', 'user-management-flows.spec.ts')")); - - // All options in alphabetical order - AddOption(new Option(["--browser", "-b"], () => "all", "Browser to use for tests (chromium, firefox, webkit, safari, all)")); - AddOption(new Option(["--debug"], () => false, "Start with Playwright Inspector for debugging (automatically enables headed mode)")); - AddOption(new Option(["--debug-timing"], () => false, "Show step timing output with color coding during test execution")); - AddOption(new Option(["--headed"], () => false, "Show browser UI while running tests (automatically enables sequential execution)")); - AddOption(new Option(["--include-slow"], () => false, "Include tests marked as @slow")); - AddOption(new Option(["--last-failed"], () => false, "Only re-run the failures")); - AddOption(new Option(["--only-changed"], () => false, "Only run test files that have uncommitted changes")); - AddOption(new Option(["--quiet"], () => false, "Suppress all output including terminal output and automatic report opening")); - AddOption(new Option(["--repeat-each"], "Number of times to repeat each test")); - AddOption(new Option(["--delete-artifacts"], () => false, "Delete all test artifacts and exit")); - AddOption(new Option(["--retries"], "Maximum retry count for flaky tests, zero for no retries")); - AddOption(new Option(["", "--self-contained-system", "-s"], $"The name of the self-contained system to test ({string.Join(", ", AvailableSelfContainedSystems)}, etc.)")); - AddOption(new Option(["--show-report"], () => false, "Always show HTML report after test run")); - AddOption(new Option(["--slow-mo"], () => false, "Run tests in slow motion (automatically enables headed mode)")); - AddOption(new Option(["--smoke"], () => false, "Run only smoke tests")); - AddOption(new Option(["--stop-on-first-failure", "-x"], () => false, "Stop after the first failure")); - AddOption(new Option(["--ui"], () => false, "Run tests in interactive UI mode with time-travel debugging")); - AddOption(new Option(["--workers", "-w"], "Number of worker processes to use for running tests")); - - Handler = CommandHandler.Create(Execute); + var searchTermsArgument = new Argument("search-terms") { Description = "Search terms for test filtering (e.g., 'user management', '@smoke', 'smoke', 'comprehensive', 'user-management-flows.spec.ts')", DefaultValueFactory = _ => [] }; + var browserOption = new Option("--browser", "-b") { Description = "Browser to use for tests (chromium, firefox, webkit, safari, all)", DefaultValueFactory = _ => "all" }; + var debugOption = new Option("--debug") { Description = "Start with Playwright Inspector for debugging (automatically enables headed mode)" }; + var debugTimingOption = new Option("--debug-timing") { Description = "Show step timing output with color coding during test execution" }; + var headedOption = new Option("--headed") { Description = "Show browser UI while running tests (automatically enables sequential execution)" }; + var includeSlowOption = new Option("--include-slow") { Description = "Include tests marked as @slow" }; + var lastFailedOption = new Option("--last-failed") { Description = "Only re-run the failures" }; + var onlyChangedOption = new Option("--only-changed") { Description = "Only run test files that have uncommitted changes" }; + var quietOption = new Option("--quiet") { Description = "Suppress all output including terminal output and automatic report opening" }; + var repeatEachOption = new Option("--repeat-each") { Description = "Number of times to repeat each test" }; + var deleteArtifactsOption = new Option("--delete-artifacts") { Description = "Delete all test artifacts and exit" }; + var retriesOption = new Option("--retries") { Description = "Maximum retry count for flaky tests, zero for no retries" }; + var selfContainedSystemOption = new Option("", "--self-contained-system", "-s") { Description = $"The name of the self-contained system to test ({string.Join(", ", AvailableSelfContainedSystems)}, etc.)" }; + var showReportOption = new Option("--show-report") { Description = "Always show HTML report after test run" }; + var slowMoOption = new Option("--slow-mo") { Description = "Run tests in slow motion (automatically enables headed mode)" }; + var smokeOption = new Option("--smoke") { Description = "Run only smoke tests" }; + var stopOnFirstFailureOption = new Option("--stop-on-first-failure", "-x") { Description = "Stop after the first failure" }; + var uiOption = new Option("--ui") { Description = "Run tests in interactive UI mode with time-travel debugging" }; + var workersOption = new Option("--workers", "-w") { Description = "Number of worker processes to use for running tests" }; + + Arguments.Add(searchTermsArgument); + Options.Add(browserOption); + Options.Add(debugOption); + Options.Add(debugTimingOption); + Options.Add(headedOption); + Options.Add(includeSlowOption); + Options.Add(lastFailedOption); + Options.Add(onlyChangedOption); + Options.Add(quietOption); + Options.Add(repeatEachOption); + Options.Add(deleteArtifactsOption); + Options.Add(retriesOption); + Options.Add(selfContainedSystemOption); + Options.Add(showReportOption); + Options.Add(slowMoOption); + Options.Add(smokeOption); + Options.Add(stopOnFirstFailureOption); + Options.Add(uiOption); + Options.Add(workersOption); + + // SetHandler only supports up to 8 parameters, so we use SetAction for this complex command + this.SetAction(parseResult => + { + Execute( + parseResult.GetValue(searchTermsArgument)!, + parseResult.GetValue(browserOption)!, + parseResult.GetValue(debugOption), + parseResult.GetValue(debugTimingOption), + parseResult.GetValue(headedOption), + parseResult.GetValue(includeSlowOption), + parseResult.GetValue(lastFailedOption), + parseResult.GetValue(onlyChangedOption), + parseResult.GetValue(quietOption), + parseResult.GetValue(repeatEachOption), + parseResult.GetValue(deleteArtifactsOption), + parseResult.GetValue(retriesOption), + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(showReportOption), + parseResult.GetValue(slowMoOption), + parseResult.GetValue(smokeOption), + parseResult.GetValue(stopOnFirstFailureOption), + parseResult.GetValue(uiOption), + parseResult.GetValue(workersOption) + ); + }); } private static string BaseUrl => Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000"; diff --git a/developer-cli/Commands/FormatCommand.cs b/developer-cli/Commands/FormatCommand.cs index 7b7c4f0299..eeb761a97d 100644 --- a/developer-cli/Commands/FormatCommand.cs +++ b/developer-cli/Commands/FormatCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Diagnostics; using System.Text.Json; using System.Xml.Linq; @@ -13,11 +12,19 @@ public class FormatCommand : Command { public FormatCommand() : base("format", "Formats code to match code styling rules") { - AddOption(new Option(["--backend", "-b"], "Only format backend code")); - AddOption(new Option(["--frontend", "-f"], "Only format frontend code")); - AddOption(new Option(["", "--solution-name", "-s"], "The name of the self-contained system to format (only used for backend code)")); - - Handler = CommandHandler.Create(Execute); + var backendOption = new Option("--backend", "-b") { Description = "Only format backend code" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Only format frontend code" }; + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to format (only used for backend code)" }; + + Options.Add(backendOption); + Options.Add(frontendOption); + Options.Add(solutionNameOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(solutionNameOption) + )); } private static void Execute(bool backend, bool frontend, string? solutionName) diff --git a/developer-cli/Commands/InspectCommand.cs b/developer-cli/Commands/InspectCommand.cs index 452ce48e0e..7d723e2517 100644 --- a/developer-cli/Commands/InspectCommand.cs +++ b/developer-cli/Commands/InspectCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -11,12 +10,22 @@ public class InspectCommand : Command { public InspectCommand() : base("inspect", "Run code inspections for frontend and backend code") { - AddOption(new Option(["--backend", "-b"], "Run only backend inspections")); - AddOption(new Option(["--frontend", "-f"], "Run only frontend inspections")); - AddOption(new Option(["", "--solution-name", "-s"], "The name of the self-contained system to inspect (only used for backend inspections)")); - AddOption(new Option(["--no-build"], () => false, "Skip building and restoring the solution before running inspections")); + var backendOption = new Option("--backend", "-b") { Description = "Run only backend inspections" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Run only frontend inspections" }; + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the self-contained system to inspect (only used for backend inspections)" }; + var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring the solution before running inspections" }; - Handler = CommandHandler.Create(Execute); + Options.Add(backendOption); + Options.Add(frontendOption); + Options.Add(solutionNameOption); + Options.Add(noBuildOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(solutionNameOption), + parseResult.GetValue(noBuildOption) + )); } private static void Execute(bool backend, bool frontend, string? solutionName, bool noBuild) diff --git a/developer-cli/Commands/InstallCommand.cs b/developer-cli/Commands/InstallCommand.cs index f8ad42c022..d17cbcd92d 100644 --- a/developer-cli/Commands/InstallCommand.cs +++ b/developer-cli/Commands/InstallCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using Spectre.Console; @@ -41,7 +40,7 @@ public InstallCommand() : base( $"This will register the alias {Configuration.AliasName} so it will be available everywhere" ) { - Handler = CommandHandler.Create(Execute); + this.SetAction(_ => Execute()); } private static void Execute() diff --git a/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs b/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs index 702ce31b29..aa91e540d3 100644 --- a/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs +++ b/developer-cli/Commands/PullPlatformPlatformChangesCommand.cs @@ -1,5 +1,5 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; +using System.CommandLine.Invocation; using System.Text; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -16,12 +16,22 @@ public class PullPlatformPlatformChangesCommand : Command public PullPlatformPlatformChangesCommand() : base("pull-platformplatform-changes", "Pull new updates from PlatformPlatform into a pull-request branch") { - AddOption(new Option(["--verbose-logging"], "Show git command and output")); - AddOption(new Option(["--auto-confirm", "-a"], "Auto confirm picking all upstream pull-requests")); - AddOption(new Option(["--resume", "-r"], "Validate current branch and resume pulling updates starting with rerunning checks")); - AddOption(new Option(["--run-format", "-s"], "Run JetBrains format of backend code (slow)")); - - Handler = CommandHandler.Create(Execute); + var verboseLoggingOption = new Option("--verbose-logging") { Description = "Show git command and output" }; + var autoConfirmOption = new Option("--auto-confirm", "-a") { Description = "Auto confirm picking all upstream pull-requests" }; + var resumeOption = new Option("--resume", "-r") { Description = "Validate current branch and resume pulling updates starting with rerunning checks" }; + var runFormatOption = new Option("--run-format", "-s") { Description = "Run JetBrains format of backend code (slow)" }; + + Options.Add(verboseLoggingOption); + Options.Add(autoConfirmOption); + Options.Add(resumeOption); + Options.Add(runFormatOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(verboseLoggingOption), + parseResult.GetValue(autoConfirmOption), + parseResult.GetValue(resumeOption), + parseResult.GetValue(runFormatOption) + )); } private static void Execute(bool verboseLogging, bool autoConfirm, bool resume, bool runCodeFormat) @@ -271,7 +281,7 @@ private static void BuildTestAndFormatCode(bool runCodeFormat) ? new[] { "--skip-inspect" } : new[] { "--skip-format", "--skip-inspect" }; - checkCommand.InvokeAsync(args); + checkCommand.Parse(args).Invoke(); break; } catch (Exception) diff --git a/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs b/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs index 6f3db280b9..49df2b6606 100644 --- a/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs +++ b/developer-cli/Commands/SyncWindsurfAiRulesAndWorkflowsCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Security.Cryptography; using PlatformPlatform.DeveloperCli.Installation; using Spectre.Console; @@ -10,7 +9,7 @@ public sealed class SyncWindsurfAiRulesAndWorkflowsCommand : Command { public SyncWindsurfAiRulesAndWorkflowsCommand() : base("sync-windsurf-ai-rules", "Sync Windsurf AI rules from .cursor/rules to .windsurf/rules and .windsurf/workflows, converting frontmatter and deleting orphans.") { - Handler = CommandHandler.Create(Execute); + this.SetAction(_ => Execute()); } private static void Execute() diff --git a/developer-cli/Commands/TestCommand.cs b/developer-cli/Commands/TestCommand.cs index cfd998c093..21f3731bcc 100644 --- a/developer-cli/Commands/TestCommand.cs +++ b/developer-cli/Commands/TestCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -9,10 +8,16 @@ public class TestCommand : Command { public TestCommand() : base("test", "Runs tests from a solution") { - AddOption(new Option(["", "--solution-name", "-s"], "The name of the solution file containing the tests to run")); - AddOption(new Option(["--no-build"], () => false, "Skip building and restoring the solution before running tests")); + var solutionNameOption = new Option("", "--solution-name", "-s") { Description = "The name of the solution file containing the tests to run" }; + var noBuildOption = new Option("--no-build") { Description = "Skip building and restoring the solution before running tests" }; - Handler = CommandHandler.Create(Execute); + Options.Add(solutionNameOption); + Options.Add(noBuildOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(solutionNameOption), + parseResult.GetValue(noBuildOption) + )); } private void Execute(string? solutionName, bool noBuild) diff --git a/developer-cli/Commands/TranslateCommand.cs b/developer-cli/Commands/TranslateCommand.cs index 20373fee56..e7ef82d589 100644 --- a/developer-cli/Commands/TranslateCommand.cs +++ b/developer-cli/Commands/TranslateCommand.cs @@ -1,6 +1,5 @@ using System.ClientModel; using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Text; using Azure.AI.OpenAI; using Karambolo.PO; @@ -18,9 +17,16 @@ public TranslateCommand() : base( $"Update language files with missing translations powered by {OpenAiTranslationService.ModelName}" ) { - AddOption(new Option(["--self-contained-system", "-s"], "Translate only files in a specific self-contained system")); - AddOption(new Option(["--language", "-l"], "Translate only files for a specific language (e.g. da-DK)")); - Handler = CommandHandler.Create(Execute); + var selfContainedSystemOption = new Option("--self-contained-system", "-s") { Description = "Translate only files in a specific self-contained system" }; + var languageOption = new Option("--language", "-l") { Description = "Translate only files for a specific language (e.g. da-DK)" }; + + Options.Add(selfContainedSystemOption); + Options.Add(languageOption); + + this.SetAction(async parseResult => await Execute( + parseResult.GetValue(selfContainedSystemOption), + parseResult.GetValue(languageOption) + )); } private static async Task Execute(string? selfContainedSystem, string? language) diff --git a/developer-cli/Commands/UninstallCommand.cs b/developer-cli/Commands/UninstallCommand.cs index c2adbd4944..ca0cabdb75 100644 --- a/developer-cli/Commands/UninstallCommand.cs +++ b/developer-cli/Commands/UninstallCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using Spectre.Console; @@ -9,7 +8,7 @@ public class UninstallCommand : Command { public UninstallCommand() : base("uninstall", $"Will remove the {Configuration.AliasName} CLI alias") { - Handler = CommandHandler.Create(Execute); + this.SetAction(_ => Execute()); } private void Execute() diff --git a/developer-cli/Commands/UpdatePackagesCommand.cs b/developer-cli/Commands/UpdatePackagesCommand.cs index 41cf14a098..0ce96a7ab7 100644 --- a/developer-cli/Commands/UpdatePackagesCommand.cs +++ b/developer-cli/Commands/UpdatePackagesCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -20,12 +19,25 @@ public sealed class UpdatePackagesCommand : Command public UpdatePackagesCommand() : base("update-packages", "Updates packages to their latest versions while preserving major versions for restricted packages") { - AddOption(new Option(["--backend", "-b"], "Update only backend packages (NuGet)")); - AddOption(new Option(["--frontend", "-f"], "Update only frontend packages (npm)")); - AddOption(new Option(["--dry-run", "-d"], "Show what would be updated without making changes")); - AddOption(new Option(["--exclude", "-e"], "Comma-separated list of packages to exclude from updates")); - AddOption(new Option(["--skip-update-dotnet"], "Skip updating .NET SDK version in global.json")); - Handler = CommandHandler.Create(Execute); + var backendOption = new Option("--backend", "-b") { Description = "Update only backend packages (NuGet)" }; + var frontendOption = new Option("--frontend", "-f") { Description = "Update only frontend packages (npm)" }; + var dryRunOption = new Option("--dry-run", "-d") { Description = "Show what would be updated without making changes" }; + var excludeOption = new Option("--exclude", "-e") { Description = "Comma-separated list of packages to exclude from updates" }; + var skipUpdateDotnetOption = new Option("--skip-update-dotnet") { Description = "Skip updating .NET SDK version in global.json" }; + + Options.Add(backendOption); + Options.Add(frontendOption); + Options.Add(dryRunOption); + Options.Add(excludeOption); + Options.Add(skipUpdateDotnetOption); + + this.SetAction(async parseResult => await Execute( + parseResult.GetValue(backendOption), + parseResult.GetValue(frontendOption), + parseResult.GetValue(dryRunOption), + parseResult.GetValue(excludeOption), + parseResult.GetValue(skipUpdateDotnetOption) + )); } private static async Task Execute(bool backend, bool frontend, bool dryRun, string? exclude, bool skipUpdateDotnet) diff --git a/developer-cli/Commands/WatchCommand.cs b/developer-cli/Commands/WatchCommand.cs index e486ecc2a0..93c77a4a60 100644 --- a/developer-cli/Commands/WatchCommand.cs +++ b/developer-cli/Commands/WatchCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.NamingConventionBinder; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; using Spectre.Console; @@ -17,13 +16,25 @@ public class WatchCommand : Command public WatchCommand() : base("watch", "Manages Aspire AppHost operations") { - AddOption(new Option(["--force"], "Force start a fresh Aspire AppHost instance, stopping any existing one")); - AddOption(new Option(["--stop"], "Stop any running Aspire AppHost instance without starting a new one")); - AddOption(new Option(["--attach", "-a"], "Keep the CLI process attached to the Aspire process")); - AddOption(new Option(["--detach", "-d"], "Run the Aspire process in detached mode (background)")); - AddOption(new Option(["--public-url"], "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)")); - - Handler = CommandHandler.Create(Execute); + var forceOption = new Option("--force") { Description = "Force start a fresh Aspire AppHost instance, stopping any existing one" }; + var stopOption = new Option("--stop") { Description = "Stop any running Aspire AppHost instance without starting a new one" }; + var attachOption = new Option("--attach", "-a") { Description = "Keep the CLI process attached to the Aspire process" }; + var detachOption = new Option("--detach", "-d") { Description = "Run the Aspire process in detached mode (background)" }; + var publicUrlOption = new Option("--public-url") { Description = "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)" }; + + Options.Add(forceOption); + Options.Add(stopOption); + Options.Add(attachOption); + Options.Add(detachOption); + Options.Add(publicUrlOption); + + this.SetAction(parseResult => Execute( + parseResult.GetValue(forceOption), + parseResult.GetValue(stopOption), + parseResult.GetValue(attachOption), + parseResult.GetValue(detachOption), + parseResult.GetValue(publicUrlOption) + )); } private static void Execute(bool force, bool stop, bool attach, bool detach, string? publicUrl) diff --git a/developer-cli/DeveloperCli.csproj b/developer-cli/DeveloperCli.csproj index 985d1dc303..2a592540ae 100644 --- a/developer-cli/DeveloperCli.csproj +++ b/developer-cli/DeveloperCli.csproj @@ -21,8 +21,7 @@ - - + diff --git a/developer-cli/Program.cs b/developer-cli/Program.cs index a852a75c06..9ece06edd8 100644 --- a/developer-cli/Program.cs +++ b/developer-cli/Program.cs @@ -1,6 +1,5 @@ using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Parsing; +using System.CommandLine.Invocation; using System.Reflection; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; @@ -49,11 +48,10 @@ allCommands.Remove(allCommands.First(c => c.Name == "install")); } -allCommands.ForEach(rootCommand.AddCommand); - -// Create a CommandLineBuilder with the root command -var builder = new CommandLineBuilder(rootCommand); - -builder.UseDefaults(); +foreach (var command in allCommands) +{ + rootCommand.Subcommands.Add(command); +} -await builder.Build().InvokeAsync(args); +var parseResult = rootCommand.Parse(args); +return await parseResult.InvokeAsync(); From b53873fd1e5c4f8f20c582c53ea66c2d9e9f792f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 23 Nov 2025 22:06:45 +0100 Subject: [PATCH 11/11] Update documentation to reflect .NET 10 upgrade and Aspire rebranding --- .cursor/rules/main.mdc | 12 ++++++------ .windsurf/rules/backend/commands.md | 6 ++---- .windsurf/rules/main.md | 12 ++++++------ README.md | 12 ++++++------ 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/.cursor/rules/main.mdc b/.cursor/rules/main.mdc index 23a055c4bb..f2ebd3124f 100644 --- a/.cursor/rules/main.mdc +++ b/.cursor/rules/main.mdc @@ -42,18 +42,18 @@ This is a mono repository with multiple self-contained systems (SCS), each being - [application](mdc:application): Contains application code: - [account-management](mdc:application/account-management): An SCS for tenant and user management: - [WebApp](mdc:application/account-management/WebApp): A React, TypeScript SPA. - - [Api](mdc:application/account-management/Api): .NET 9 minimal API. - - [Core](mdc:application/account-management/Core): .NET 9 Vertical Sliced Architecture. + - [Api](mdc:application/account-management/Api): .NET 10 minimal API. + - [Core](mdc:application/account-management/Core): .NET 10 Vertical Sliced Architecture. - [Workers](mdc:application/account-management/Workers): A .NET Console job. - [Tests](mdc:application/account-management/Tests): xUnit tests for backend. - [back-office](mdc:application/back-office): An empty SCS that will be used to create tools for Support and System Admins: - [WebApp](mdc:application/back-office/WebApp): A React, TypeScript SPA. - - [Api](mdc:application/back-office/Api): .NET 9 minimal API. - - [Core](mdc:application/back-office/Core): .NET 9 Vertical Sliced Architecture. + - [Api](mdc:application/back-office/Api): .NET 10 minimal API. + - [Core](mdc:application/back-office/Core): .NET 10 Vertical Sliced Architecture. - [Workers](mdc:application/back-office/Workers): A .NET Console job. - [Tests](mdc:application/back-office/Tests): xUnit tests for backend. - - [AppHost](mdc:application/AppHost): .NET Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode. - - [AppGateway](mdc:application/AppGateway): Main entry point using .NET YARP as reverse proxy for all SCSs. + - [AppHost](mdc:application/AppHost): Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode. + - [AppGateway](mdc:application/AppGateway): Main entry point using YARP as reverse proxy for all SCSs. - [shared-kernel](mdc:application/shared-kernel): Reusable .NET backend shared by all SCSs. - [shared-webapp](mdc:application/shared-webapp): Reusable frontend shared by all SCSs. - [cloud-infrastructure](mdc:cloud-infrastructure): Bash and Azure Bicep scripts (IaC). diff --git a/.windsurf/rules/backend/commands.md b/.windsurf/rules/backend/commands.md index ecf23dc613..6b7528d202 100644 --- a/.windsurf/rules/backend/commands.md +++ b/.windsurf/rules/backend/commands.md @@ -63,9 +63,7 @@ public sealed class CreateUserValidator : AbstractValidator public CreateUserValidator() { // ✅ DO: Use the same message for better user experience and easier localization - RuleFor(x => x.Name) - .NotEmpty().WithMessage("Name must be between 1 and 50 characters.") - .MaximumLength(50).WithMessage("Name must be between 1 and 50 characters."); + RuleFor(x => x.Name).Length(1, 50).WithMessage("Name must be between 1 and 50 characters."); } } @@ -99,7 +97,7 @@ public sealed class CreateUserValidator : AbstractValidator { public CreateUserValidator() { - // ❌ DON'T: Use different validation messages for the same property + // ❌ DON'T: Use different validation messages for the same property and redundant validation rules RuleFor(x => x.Name) .NotEmpty().WithMessage("Name must not be empty.") .MaximumLength(50).WithMessage("Name must not be more than 50 characters."); diff --git a/.windsurf/rules/main.md b/.windsurf/rules/main.md index 9db6977f0f..d787fc530b 100644 --- a/.windsurf/rules/main.md +++ b/.windsurf/rules/main.md @@ -42,18 +42,18 @@ This is a mono repository with multiple self-contained systems (SCS), each being - [application](/application): Contains application code: - [account-management](/application/account-management): An SCS for tenant and user management: - [WebApp](/application/account-management/WebApp): A React, TypeScript SPA. - - [Api](/application/account-management/Api): .NET 9 minimal API. - - [Core](/application/account-management/Core): .NET 9 Vertical Sliced Architecture. + - [Api](/application/account-management/Api): .NET 10 minimal API. + - [Core](/application/account-management/Core): .NET 10 Vertical Sliced Architecture. - [Workers](/application/account-management/Workers): A .NET Console job. - [Tests](/application/account-management/Tests): xUnit tests for backend. - [back-office](/application/back-office): An empty SCS that will be used to create tools for Support and System Admins: - [WebApp](/application/back-office/WebApp): A React, TypeScript SPA. - - [Api](/application/back-office/Api): .NET 9 minimal API. - - [Core](/application/back-office/Core): .NET 9 Vertical Sliced Architecture. + - [Api](/application/back-office/Api): .NET 10 minimal API. + - [Core](/application/back-office/Core): .NET 10 Vertical Sliced Architecture. - [Workers](/application/back-office/Workers): A .NET Console job. - [Tests](/application/back-office/Tests): xUnit tests for backend. - - [AppHost](/application/AppHost): .NET Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode. - - [AppGateway](/application/AppGateway): Main entry point using .NET YARP as reverse proxy for all SCSs. + - [AppHost](/application/AppHost): Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode. + - [AppGateway](/application/AppGateway): Main entry point using YARP as reverse proxy for all SCSs. - [shared-kernel](/application/shared-kernel): Reusable .NET backend shared by all SCSs. - [shared-webapp](/application/shared-webapp): Reusable frontend shared by all SCSs. - [cloud-infrastructure](/cloud-infrastructure): Bash and Azure Bicep scripts (IaC). diff --git a/README.md b/README.md index d242873408..25213f991e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Built to demonstrate seamless flow—backend contracts feed a fully-typed React ## What's inside -* **Backend** - .NET 9 and C# adhering to the principles of vertical slice architecture, DDD, CQRS, and clean code +* **Backend** - .NET 10 and C# 14 adhering to the principles of vertical slice architecture, DDD, CQRS, and clean code * **Frontend** – React 19, TypeScript, TanStack Router & Query, React Aria for accessible and UI * **CI/CD** - GitHub actions for fast passwordless deployments of docker containers and infrastructure (Bicep) * **Infrastructure** - Cost efficient and scalable Azure PaaS services like Azure Container Apps, Azure SQL, etc. @@ -59,7 +59,7 @@ For development, you need .NET, Docker, and Node. And GitHub and Azure CLI for s 2. From an Administrator PowerShell terminal, use [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/) (preinstalled on Windows 11) to install any missing packages: ```powershell - winget install Microsoft.DotNet.SDK.9 + winget install Microsoft.DotNet.SDK.10 winget install Git.Git winget install Docker.DockerDesktop winget install OpenJS.NodeJS @@ -125,10 +125,10 @@ Open a terminal and run the following commands (if not installed): sudo apt-get update ``` -- Install .NET SDK 9.0, Node, GitHub CLI +- Install .NET SDK 10.0, Node, GitHub CLI ```bash - sudo apt-get install -y dotnet-sdk-9.0 nodejs gh + sudo apt-get install -y dotnet-sdk-10.0 nodejs gh ``` - Install Azure CLI @@ -233,11 +233,11 @@ PlatformPlatform is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) contain # Technologies -### .NET 9 Backend With Vertical Sliced Architecture, DDD, CQRS, Minimal API, and Aspire +### .NET 10 Backend With Vertical Sliced Architecture, DDD, CQRS, Minimal API, and Aspire The backend is built using the most popular, mature, and commonly used technologies in the .NET ecosystem: -- [.NET 9](https://dotnet.microsoft.com) and [C# 13](https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp) +- [.NET 10](https://dotnet.microsoft.com) and [C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp) - [Aspire](https://aka.ms/dotnet-aspire) - [YARP](https://microsoft.github.io/reverse-proxy) - [ASP.NET Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis)