diff --git a/build/dependencies.props b/build/dependencies.props index bb945fe90..82ac74996 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -66,24 +66,26 @@ 5.0.17 6.0.14 8.0.0-preview.1.23111.4 + + 8.0.0-preview.3.23177.8 - + 2.1.3 - + 2.2.0 - + 3.0.0 - + 3.1.0 - + 6.0.14 - - 8.0.0-preview.1.23112.2 + + $(MicrosoftAspNetCoreAppPackageVersion80) diff --git a/build/nativeaot-scenarios.yml b/build/nativeaot-scenarios.yml index 651ecbd35..9efd03fbc 100644 --- a/build/nativeaot-scenarios.yml +++ b/build/nativeaot-scenarios.yml @@ -40,6 +40,32 @@ parameters: arguments: --scenario basicminimalapipublishaot $(goldilocksJobs) --property scenario=Stage1AotServerGC --property publish=nativeaot --application.packageReferences \"Microsoft.Dotnet.ILCompiler=$(MicrosoftNETCoreAppPackageVersion)\" --application.buildArguments \"/p:ServerGarbageCollection=true\" condition: 'true' + - displayName: Goldilocks Stage 2 (CoreCLR) + arguments: --scenario todosapivanilla $(goldilocksJobs) --property scenario=Stage2 --property publish=coreclr + condition: 'true' + + - displayName: Goldilocks Stage 2 (CoreCLR - Server GC) + arguments: --scenario todosapivanilla $(goldilocksJobs) --property scenario=Stage2ServerGC --property publish=coreclr --application.buildArguments \"/p:ServerGarbageCollection=true\" + condition: 'true' + + - displayName: Goldilocks Stage 2 (CoreCLR - PGO) + arguments: --scenario todosapivanilla $(goldilocksJobs) --property scenario=Stage2Pgo --property publish=coreclr --application.environmentVariables DOTNET_TieredPGO=1 + condition: Math.round(Date.now() / 43200000) % 6 == 1 # once every 6 half-days (43200000 ms per half-day) + + - displayName: Goldilocks Stage 2 (CoreCLR - Trim R2R SingleFile) + arguments: --scenario todosapipublishtrimr2rsinglefile $(goldilocksJobs) --property scenario=Stage2TrimR2RSingleFile --property publish=coreclr + condition: 'true' + + - displayName: Goldilocks Stage 2 (NativeAOT - Workstation GC) + # workaround https://github.com/dotnet/runtime/issues/81382 by explicitly referencing a Microsoft.Dotnet.ILCompiler version + arguments: --scenario todosapipublishaot $(goldilocksJobs) --property scenario=Stage2Aot --property publish=nativeaot --application.packageReferences \"Microsoft.Dotnet.ILCompiler=$(MicrosoftNETCoreAppPackageVersion)\" + condition: 'true' + + - displayName: Goldilocks Stage 2 (NativeAOT - Server GC) + # workaround https://github.com/dotnet/runtime/issues/81382 by explicitly referencing a Microsoft.Dotnet.ILCompiler version + arguments: --scenario todosapipublishaot $(goldilocksJobs) --property scenario=Stage2AotServerGC --property publish=nativeaot --application.packageReferences \"Microsoft.Dotnet.ILCompiler=$(MicrosoftNETCoreAppPackageVersion)\" --application.buildArguments \"/p:ServerGarbageCollection=true\" + condition: 'true' + - displayName: Goldilocks gRPC Stage 1 (CoreCLR) arguments: --scenario basicgrpcvanilla $(goldilocksJobs) --property scenario=Stage1Grpc --property publish=coreclr condition: 'true' diff --git a/scenarios/goldilocks.benchmarks.yml b/scenarios/goldilocks.benchmarks.yml index a407980fe..55f9a16c5 100644 --- a/scenarios/goldilocks.benchmarks.yml +++ b/scenarios/goldilocks.benchmarks.yml @@ -21,6 +21,16 @@ jobs: serverScheme: http serverPort: 5000 arguments: "--urls {{serverScheme}}://{{serverAddress}}:{{serverPort}}" + todosapiaspnetbenchmarks: + source: + repository: https://github.com/aspnet/benchmarks.git + branchOrCommit: main + project: src/BenchmarksApps/TodosApi/TodosApi.csproj + readyStateText: Application started. + variables: + serverScheme: http + serverPort: 5000 + arguments: "--urls {{serverScheme}}://{{serverAddress}}:{{serverPort}}" basicgrpcaspnetbenchmarks: source: repository: https://github.com/aspnet/benchmarks.git @@ -42,6 +52,16 @@ jobs: waitForExit: false options: requiredOperatingSystem: linux + postgresql: + source: + repository: https://github.com/TechEmpower/FrameworkBenchmarks.git + branchOrCommit: master + dockerFile: toolset/databases/postgres/postgres.dockerfile + dockerImageName: postgres_te + dockerContextDirectory: toolset/databases/postgres + readyStateText: ready to accept connections + noClean: true + scenarios: @@ -82,6 +102,58 @@ scenarios: presetHeaders: json path: /todos + todosapipublishaot: + db: + job: postgresql + application: + job: todosapiaspnetbenchmarks + buildArguments: + - "/p:PublishAot=true" + - "/p:StripSymbols=true" + environmentVariables: + CONNECTIONSTRINGS__TODODB: Server={{databaseServer}};Database=hello_world;User Id=benchmarkdbuser;Password=benchmarkdbpass;Maximum Pool Size=256;NoResetOnClose=true;Enlist=false;Max Auto Prepare=4 + load: + job: wrk + variables: + presetHeaders: json + path: /api/todos + + todosapipublishtrimr2rsinglefile: + db: + job: postgresql + application: + job: todosapiaspnetbenchmarks + buildArguments: + - "/p:PublishAot=false" + - "/p:PublishTrimmed=true" + - "/p:PublishReadyToRun=true" + - "/p:PublishSingleFile=true" + - "/p:TrimMode=full" + - "/p:EnableRequestDelegateGenerator=true" + environmentVariables: + CONNECTIONSTRINGS__TODODB: Server={{databaseServer}};Database=hello_world;User Id=benchmarkdbuser;Password=benchmarkdbpass;Maximum Pool Size=256;NoResetOnClose=true;Enlist=false;Max Auto Prepare=4 + load: + job: wrk + variables: + presetHeaders: json + path: /api/todos + + todosapivanilla: + db: + job: postgresql + application: + job: todosapiaspnetbenchmarks + buildArguments: + - "/p:PublishAot=false" + - "/p:EnableRequestDelegateGenerator=false" + environmentVariables: + CONNECTIONSTRINGS__TODODB: Server={{databaseServer}};Database=hello_world;User Id=benchmarkdbuser;Password=benchmarkdbpass;Maximum Pool Size=256;NoResetOnClose=true;Enlist=false;Max Auto Prepare=4 + load: + job: wrk + variables: + presetHeaders: json + path: /api/todos + basicgrpcpublishaot: application: job: basicgrpcaspnetbenchmarks diff --git a/src/BenchmarksApps.sln b/src/BenchmarksApps.sln index 19fdde424..d3cb0961f 100644 --- a/src/BenchmarksApps.sln +++ b/src/BenchmarksApps.sln @@ -34,9 +34,6 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlatformBenchmarks", "BenchmarksApps\TechEmpower\PlatformBenchmarks\PlatformBenchmarks.csproj", "{ACA43671-AD28-4F72-AAAB-6C32B388C2F0}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{689C58F6-8DF0-4565-887F-C9A9BDA757D8}" - ProjectSection(SolutionItems) = preProject - BenchmarksApps\Directory.Build.props = BenchmarksApps\Directory.Build.props - EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildPerformance", "BenchmarksApps\BuildPerformance\BuildPerformance.csproj", "{2E953AFB-4900-4B5D-9E78-819E950CD365}" EndProject @@ -52,6 +49,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TcpEcho", "BenchmarksApps\T EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorUnited", "BenchmarksApps\TechEmpower\BlazorUnited\BlazorUnited.csproj", "{FE3606FF-CBC9-421A-A0B5-836E312E7719}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodosApi", "BenchmarksApps\TodosApi\TodosApi.csproj", "{8E1A1F61-43E4-4629-A25B-7E5FA82697D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug_Database|Any CPU = Debug_Database|Any CPU @@ -196,6 +195,14 @@ Global {FE3606FF-CBC9-421A-A0B5-836E312E7719}.Release_Database|Any CPU.Build.0 = Release_Database|Any CPU {FE3606FF-CBC9-421A-A0B5-836E312E7719}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE3606FF-CBC9-421A-A0B5-836E312E7719}.Release|Any CPU.Build.0 = Release|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Debug_Database|Any CPU.ActiveCfg = Debug_Database|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Debug_Database|Any CPU.Build.0 = Debug_Database|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Release_Database|Any CPU.ActiveCfg = Release_Database|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Release_Database|Any CPU.Build.0 = Release_Database|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E1A1F61-43E4-4629-A25B-7E5FA82697D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/BenchmarksApps/Directory.Build.props b/src/BenchmarksApps/Directory.Build.props deleted file mode 100644 index 4ed01906f..000000000 --- a/src/BenchmarksApps/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - Debug;Release;Debug_Database;Release_Database - true - - diff --git a/src/BenchmarksApps/DistributedCache/DistributedCache.csproj b/src/BenchmarksApps/DistributedCache/DistributedCache.csproj index 75e95981e..f8bd62c83 100644 --- a/src/BenchmarksApps/DistributedCache/DistributedCache.csproj +++ b/src/BenchmarksApps/DistributedCache/DistributedCache.csproj @@ -1,10 +1,13 @@ - net7.0 + net7.0 Exe enable enable - $(TargetFramework.Substring(3,3)).* + + + $(TargetFramework.Substring(3,3)).* + $(TargetFramework.Substring(3,3)).* diff --git a/src/BenchmarksApps/Mvc/Mvc.csproj b/src/BenchmarksApps/Mvc/Mvc.csproj index 435996f36..aae1589d7 100644 --- a/src/BenchmarksApps/Mvc/Mvc.csproj +++ b/src/BenchmarksApps/Mvc/Mvc.csproj @@ -2,6 +2,10 @@ net7.0 + + + $(TargetFramework.Substring(3,3)).* + $(TargetFramework.Substring(3,3)).* diff --git a/src/BenchmarksApps/SignalR/BenchmarkServer.csproj b/src/BenchmarksApps/SignalR/BenchmarkServer.csproj index 60beb1ef5..dfd3436d4 100644 --- a/src/BenchmarksApps/SignalR/BenchmarkServer.csproj +++ b/src/BenchmarksApps/SignalR/BenchmarkServer.csproj @@ -2,6 +2,10 @@ net7.0 + + + $(TargetFramework.Substring(3,3)).* + $(TargetFramework.Substring(3,3)).* diff --git a/src/BenchmarksApps/StaticFiles/StaticFiles.csproj b/src/BenchmarksApps/StaticFiles/StaticFiles.csproj index 3e645b221..a7163ba1a 100644 --- a/src/BenchmarksApps/StaticFiles/StaticFiles.csproj +++ b/src/BenchmarksApps/StaticFiles/StaticFiles.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1;net5.0 + net6.0;net7.0;net8.0 diff --git a/src/BenchmarksApps/TodosApi/DataExtensions.cs b/src/BenchmarksApps/TodosApi/DataExtensions.cs new file mode 100644 index 000000000..2cfaf6115 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/DataExtensions.cs @@ -0,0 +1,207 @@ +using System.Data; +using System.Runtime.CompilerServices; + +namespace Npgsql; + +internal static class DataExtensions +{ + public static async Task ExecuteAsync(this NpgsqlDataSource dataSource, string commandText, CancellationToken cancellationToken = default) + { + await using var cmd = dataSource.CreateCommand(commandText); + + return await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + public static async Task ExecuteScalarAsync(this NpgsqlDataSource dataSource, string commandText, CancellationToken cancellationToken = default) + { + await using var cmd = dataSource.CreateCommand(commandText); + + return await cmd.ExecuteScalarAsync(cancellationToken); + } + + public static Task ExecuteAsync(this NpgsqlDataSource dataSource, string commandText, params NpgsqlParameter[] parameters) + => ExecuteAsync(dataSource, commandText, default, parameters); + + public static async Task ExecuteAsync(this NpgsqlDataSource dataSource, string commandText, CancellationToken cancellationToken, params NpgsqlParameter[] parameters) + { + await using var cmd = dataSource.CreateCommand(commandText, parameters); + + return await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + public static async Task ExecuteAsync(this NpgsqlDataSource dataSource, string commandText, Action configureParameters, CancellationToken cancellationToken = default) + { + await using var cmd = dataSource.CreateCommand(commandText, configureParameters); + + return await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + public static Task QuerySingleAsync(this NpgsqlDataSource dataSource, string commandText, params NpgsqlParameter[] parameters) + where T : IDataReaderMapper + => QuerySingleAsync(dataSource, commandText, default, parameters); + + public static async Task QuerySingleAsync(this NpgsqlDataSource dataSource, string commandText, CancellationToken cancellationToken, params NpgsqlParameter[] parameters) + where T : IDataReaderMapper + { + await using var reader = await dataSource.QuerySingleAsync(commandText, cancellationToken, parameters); + + return await reader.MapSingleAsync(); + } + + public static async Task QuerySingleAsync(this NpgsqlDataSource dataSource, string commandText, Action? configureParameters = null, CancellationToken cancellationToken = default) + where T : IDataReaderMapper + { + await using var cmd = dataSource.CreateCommand(commandText, configureParameters); + + await using var reader = await cmd.QuerySingleAsync(cancellationToken); + + return await reader.MapSingleAsync(); + } + + public static IAsyncEnumerable QueryAsync(this NpgsqlDataSource dataSource, string commandText, params NpgsqlParameter[] parameters) + where T : IDataReaderMapper + => QueryAsync(dataSource, commandText, default, parameters); + + public static async IAsyncEnumerable QueryAsync(this NpgsqlDataSource dataSource, string commandText, [EnumeratorCancellation] CancellationToken cancellationToken, params NpgsqlParameter[] parameters) + where T : IDataReaderMapper + { + var query = dataSource.QueryAsync(commandText, parameterCollection => parameterCollection.AddRange(parameters), cancellationToken); + + await foreach (var item in query) + { + yield return item; + } + } + + public static async IAsyncEnumerable QueryAsync(this NpgsqlDataSource dataSource, string commandText, Action? configureParameters = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + where T : IDataReaderMapper + { + await using var cmd = dataSource.CreateCommand(commandText, configureParameters); + + await using var reader = await cmd.QueryAsync(cancellationToken); + + await foreach (var item in MapAsync(reader)) + { + yield return item; + } + } + + public static Task MapSingleAsync(this NpgsqlDataReader reader) + where T : IDataReaderMapper + => MapSingleAsync(reader, T.Map); + + public static async Task MapSingleAsync(this NpgsqlDataReader reader, Func mapper) + { + if (!reader.HasRows) + { + return default; + } + + await reader.ReadAsync(); + + return mapper(reader); + } + + public static IAsyncEnumerable MapAsync(this NpgsqlDataReader reader) + where T : IDataReaderMapper + => MapAsync(reader, T.Map); + + public static async IAsyncEnumerable MapAsync(this NpgsqlDataReader reader, Func mapper) + { + if (!reader.HasRows) + { + yield break; + } + + while (await reader.ReadAsync()) + { + yield return mapper(reader); + } + } + + public static Task QuerySingleAsync(this NpgsqlDataSource dataSource, string commandText, params NpgsqlParameter[] parameters) + => QuerySingleAsync(dataSource, commandText, default, parameters); + + public static Task QuerySingleAsync(this NpgsqlDataSource dataSource, string commandText, CancellationToken cancellationToken, params NpgsqlParameter[] parameters) + => QueryAsync(dataSource, commandText, CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken, parameters); + + public static Task QuerySingleAsync(this NpgsqlCommand command, CancellationToken cancellationToken = default) + => QueryAsync(command, CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken); + + public static Task QueryAsync(this NpgsqlDataSource dataSource, string commandText, CommandBehavior commandBehavior, params NpgsqlParameter[] parameters) + => QueryAsync(dataSource, commandText, commandBehavior, default, parameters); + + public static async Task QueryAsync(this NpgsqlDataSource dataSource, string commandText, CommandBehavior commandBehavior, CancellationToken cancellationToken, params NpgsqlParameter[] parameters) + { + await using var cmd = dataSource.CreateCommand(commandText, parameters); + + return await cmd.ExecuteReaderAsync(commandBehavior, cancellationToken); + } + + public static Task QueryAsync(this NpgsqlCommand command, CancellationToken cancellationToken = default) + => QueryAsync(command, CommandBehavior.Default, cancellationToken); + + public static Task QueryAsync(this NpgsqlCommand command, CommandBehavior commandBehavior, CancellationToken cancellationToken = default) + => command.ExecuteReaderAsync(commandBehavior, cancellationToken); + + public static async Task> ToListAsync(this IAsyncEnumerable enumerable, int? initialCapacity = null, CancellationToken cancellationToken = default) + { + var list = initialCapacity.HasValue ? new List(initialCapacity.Value) : new List(); + + await foreach (var item in enumerable.WithCancellation(cancellationToken)) + { + list.Add(item); + } + + return list; + } + + public static NpgsqlParameterCollection AddTyped(this NpgsqlParameterCollection parameters, T? value) + { + parameters.Add(new NpgsqlParameter + { + TypedValue = value + }); + return parameters; + } + + public static NpgsqlParameter AsTypedDbParameter(this T value) + { + var parameter = new NpgsqlParameter + { + TypedValue = value + }; + + return parameter; + } + + private static NpgsqlCommand CreateCommand(this NpgsqlDataSource dataSource, string commandText, params NpgsqlParameter[] parameters) => + ConfigureCommand(dataSource.CreateCommand(commandText), parameters); + + private static NpgsqlCommand ConfigureCommand(NpgsqlCommand cmd, NpgsqlParameter[] parameters) => + ConfigureCommand(cmd, parameterCollection => + { + for (var i = 0; i < parameters.Length; i++) + { + parameterCollection.Add(parameters[i]); + } + }); + + private static NpgsqlCommand CreateCommand(this NpgsqlDataSource dataSource, string commandText, Action? configureParameters = null) => + ConfigureCommand(dataSource.CreateCommand(commandText), configureParameters); + + private static NpgsqlCommand ConfigureCommand(NpgsqlCommand cmd, Action? configureParameters = null) + { + if (configureParameters is not null) + { + configureParameters(cmd.Parameters); + } + + return cmd; + } +} + +internal interface IDataReaderMapper where T : IDataReaderMapper +{ + abstract static T Map(NpgsqlDataReader dataReader); +} \ No newline at end of file diff --git a/src/BenchmarksApps/TodosApi/Database.cs b/src/BenchmarksApps/TodosApi/Database.cs new file mode 100644 index 000000000..48dc1d0c1 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/Database.cs @@ -0,0 +1,41 @@ +using Npgsql; + +namespace TodosApi; + +internal static class Database +{ + public static async Task Initialize(IServiceProvider services, ILogger logger, CancellationToken cancellationToken = default) + { + var db = services.GetRequiredService(); + + if (Environment.GetEnvironmentVariable("SUPPRESS_DB_INIT") != "true") + { + // NOTE: Npgsql removes the password from the connection string + logger.LogInformation("Ensuring database exists and is up to date at connection string '{connectionString}'", db.ConnectionString); + + var sql = $""" + CREATE TABLE IF NOT EXISTS public.todos + ( + {nameof(Todo.Id)} SERIAL PRIMARY KEY, + {nameof(Todo.Title)} text NOT NULL, + {nameof(Todo.DueBy)} date NULL, + {nameof(Todo.IsComplete)} boolean NOT NULL DEFAULT false + ); + DELETE FROM public.todos; + INSERT INTO + public.todos ({nameof(Todo.Title)}, {nameof(Todo.DueBy)}, {nameof(Todo.IsComplete)}) + VALUES + ('Wash the dishes.', CURRENT_DATE, true), + ('Dry the dishes.', CURRENT_DATE, true), + ('Turn the dishes over.', CURRENT_DATE, false), + ('Walk the kangaroo.', CURRENT_DATE + INTERVAL '1 day', false), + ('Call Grandma.', CURRENT_DATE + INTERVAL '1 day', false); + """; + await db.ExecuteAsync(sql, cancellationToken); + } + else + { + logger.LogInformation("Database initialization disabled for connection string '{connectionString}'", db.ConnectionString); + } + } +} diff --git a/src/BenchmarksApps/TodosApi/DatabaseHealthCheck.cs b/src/BenchmarksApps/TodosApi/DatabaseHealthCheck.cs new file mode 100644 index 000000000..f80bc78c4 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/DatabaseHealthCheck.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Npgsql; + +namespace TodosApi; + +public class DatabaseHealthCheck : IHealthCheck +{ + private readonly NpgsqlDataSource _dataSource; + + public DatabaseHealthCheck(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + Exception? exception = null; + try + { + await _dataSource.ExecuteScalarAsync("SELECT 1", cancellationToken); + } + catch (Exception ex) + { + exception = ex; + } + + return exception switch + { + null => HealthCheckResult.Healthy("Database health verified successfully"), + _ => HealthCheckResult.Unhealthy("Error occurred when checking database health", exception: exception) + }; + } +} diff --git a/src/BenchmarksApps/TodosApi/JwtConfiguration.cs b/src/BenchmarksApps/TodosApi/JwtConfiguration.cs new file mode 100644 index 000000000..8b6df4cd7 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/JwtConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.AspNetCore.Builder; + +internal static class JwtConfiguration +{ + /// + /// Configures JWT Bearer to load the signing key from an environment variable when not running in Development. + /// + /// + /// + /// Thrown when the signing key is not found in non-Development environments. + public static Action ConfigureJwtBearer(WebApplicationBuilder builder) + { + return options => + { + if (!builder.Environment.IsDevelopment()) + { + // When not running in development configure the JWT signing key from environment variable + var jwtKeyMaterialValue = builder.Configuration["JWT_SIGNING_KEY"]; + + if (!string.IsNullOrEmpty(jwtKeyMaterialValue)) + { + var jwtKeyMaterial = Convert.FromBase64String(jwtKeyMaterialValue); + var jwtSigningKey = new SymmetricSecurityKey(jwtKeyMaterial); + options.TokenValidationParameters.IssuerSigningKey = jwtSigningKey; + } + } + }; + } +} diff --git a/src/BenchmarksApps/TodosApi/JwtHealthCheck.cs b/src/BenchmarksApps/TodosApi/JwtHealthCheck.cs new file mode 100644 index 000000000..8582d3045 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/JwtHealthCheck.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace TodosApi; + +internal class JwtHealthCheck : IHealthCheck +{ + private readonly IOptionsMonitor _jwtOptions; + private readonly ILogger _logger; + + public JwtHealthCheck(IOptionsMonitor jwtOptions, ILogger logger) + { + _jwtOptions = jwtOptions; + _logger = logger; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var valid = ValidateJwtOptions(_jwtOptions.CurrentValue); + var status = valid + ? HealthCheckResult.Healthy("JWT options configured correctly") + : HealthCheckResult.Degraded("JWT options are not configured. Verify the JWT signing key is correctly configured for the current environment."); + return Task.FromResult(status); + } + + private const string JwtOptionsLogMessage = "JwtBearerAuthentication options configuration: {JwtOptions}"; + + private bool ValidateJwtOptions(JwtBearerOptions options) + { + var relevantOptions = new JwtOptionsSummary + { + Audience = options.Audience, + ClaimsIssuer = options.ClaimsIssuer, + Audiences = options.TokenValidationParameters?.ValidAudiences, + Issuers = options.TokenValidationParameters?.ValidIssuers, + IssuerSigningKey = options.TokenValidationParameters?.IssuerSigningKey, + IssuerSigningKeys = options.TokenValidationParameters?.IssuerSigningKeys + }; + + var jwtOptionsJson = JsonSerializer.Serialize(relevantOptions, JwtOptionsJsonSerializerContext.Default.JwtOptionsSummary); + + if ((string.IsNullOrEmpty(relevantOptions.Audience) && relevantOptions.Audiences?.Any() != true) + || (relevantOptions.ClaimsIssuer is null && relevantOptions.Issuers?.Any() != true) + || (relevantOptions.IssuerSigningKey is null && relevantOptions.IssuerSigningKeys?.Any() != true)) + { + _logger.LogWarning(JwtOptionsLogMessage, jwtOptionsJson); + return false; + } + + _logger.LogInformation(JwtOptionsLogMessage, jwtOptionsJson); + return true; + } +} + +internal class JwtOptionsSummary +{ + public string? Audience { get; set; } + public string? ClaimsIssuer { get; set; } + public IEnumerable? Audiences { get; set; } + public IEnumerable? Issuers { get; set; } + public SecurityKey? IssuerSigningKey { get; set; } + public IEnumerable? IssuerSigningKeys { get; set; } +} + +[JsonSerializable(typeof(JwtOptionsSummary))] +internal partial class JwtOptionsJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/BenchmarksApps/TodosApi/Program.cs b/src/BenchmarksApps/TodosApi/Program.cs new file mode 100644 index 000000000..32cb9e343 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/Program.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging.Configuration; +using Npgsql; +using TodosApi; + +var builder = WebApplication.CreateSlimBuilder(args); + +// Load custom configuration +var settingsFiles = new[] { "appsettings.json", $"appsettings.{builder.Environment.EnvironmentName}.json" }; +foreach (var settingsFile in settingsFiles) +{ + builder.Configuration.AddJsonFile(builder.Environment.ContentRootFileProvider, settingsFile, optional: true, reloadOnChange: true); +} + +#if DEBUG || DEBUG_DATABASE +builder.Configuration.AddUserSecrets(); +#endif + +#if ENABLE_LOGGING +// Configure logging +builder.Logging + .AddConfiguration(builder.Configuration.GetSection("Logging")) + .AddSimpleConsole(); +#endif + +// Configure authentication & authorization +builder.Services.AddAuthentication() + .AddJwtBearer(JwtConfiguration.ConfigureJwtBearer(builder)); + +builder.Services.AddAuthorization(); + +// Configure data access +var connectionString = builder.Configuration.GetConnectionString("TodoDb") + ?? throw new InvalidOperationException(""" + Connection string not found. + If running locally, set the connection string in user secrets for key 'ConnectionStrings:TodoDb'. + If running after deployment, set the connection string via the environment variable 'CONNECTIONSTRINGS__TODODB'. + """); +builder.Services.AddSingleton(_ => new NpgsqlSlimDataSourceBuilder(connectionString).Build()); + +// Configure JSON serialization +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, TodoApiJsonSerializerContext.Default); +}); + +// Configure health checks +builder.Services.AddHealthChecks() + .AddCheck("Database", timeout: TimeSpan.FromSeconds(2)) + .AddCheck("JwtAuthentication"); + +// Problem details +builder.Services.AddProblemDetails(); + +var app = builder.Build(); + +await Database.Initialize(app.Services, app.Logger); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler(); +} + +app.MapHealthChecks("/health"); +// Enables testing request exception handling behavior +app.MapGet("/throw", void () => throw new InvalidOperationException("You hit the throw endpoint")); + +// These need to manually registered until https://github.com/dotnet/aspnetcore/issues/47507 is fixed +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapTodoApi(); + +#if !ENABLE_LOGGING +app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine("Application started. Press Ctrl+C to shut down.")); +app.Lifetime.ApplicationStopping.Register(() => Console.WriteLine("Application is shutting down...")); +#endif + +app.Run(); diff --git a/src/BenchmarksApps/TodosApi/Todo.cs b/src/BenchmarksApps/TodosApi/Todo.cs new file mode 100644 index 000000000..5e76782f2 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/Todo.cs @@ -0,0 +1,24 @@ +using Npgsql; + +namespace TodosApi; + +internal sealed class Todo : IDataReaderMapper +{ + public int Id { get; set; } + + public string Title { get; set; } = default!; + + public DateOnly? DueBy { get; set; } + + public bool IsComplete { get; set; } + + public static Todo Map(NpgsqlDataReader dataReader) + { + return !dataReader.HasRows ? new() : new() + { + Id = dataReader.GetInt32(dataReader.GetOrdinal(nameof(Id))), + Title = dataReader.GetString(dataReader.GetOrdinal(nameof(Title))), + IsComplete = dataReader.GetBoolean(dataReader.GetOrdinal(nameof(IsComplete))) + }; + } +} diff --git a/src/BenchmarksApps/TodosApi/TodoApi.cs b/src/BenchmarksApps/TodosApi/TodoApi.cs new file mode 100644 index 000000000..7d47a6141 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/TodoApi.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http.HttpResults; +using Npgsql; +using TodosApi; + +namespace Microsoft.AspNetCore.Routing; + +internal static class TodoApi +{ + public static RouteGroupBuilder MapTodoApi(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/todos"); + + group.MapGet("/", (NpgsqlDataSource db, CancellationToken ct) => db.QueryAsync("SELECT * FROM Todos", ct)) + .WithName("GetAllTodos"); + + group.MapGet("/complete", (NpgsqlDataSource db, CancellationToken ct) => db.QueryAsync("SELECT * FROM Todos WHERE IsComplete = true", ct)) + .WithName("GetCompleteTodos"); + + group.MapGet("/incomplete", (NpgsqlDataSource db, CancellationToken ct) => db.QueryAsync("SELECT * FROM Todos WHERE IsComplete = false", ct)) + .WithName("GetIncompleteTodos"); + + group.MapGet("/{id:int}", async Task, NotFound>> (int id, NpgsqlDataSource db, CancellationToken ct) => + await db.QuerySingleAsync( + "SELECT * FROM Todos WHERE Id = $1", ct, id.AsTypedDbParameter()) + is Todo todo + ? TypedResults.Ok(todo) + : TypedResults.NotFound()) + .WithName("GetTodoById"); + + group.MapGet("/find", async Task, NotFound>> (string title, bool? isComplete, NpgsqlDataSource db, CancellationToken ct) => + await db.QuerySingleAsync( + "SELECT * FROM Todos WHERE LOWER(Title) = LOWER($1) AND ($2 is NULL OR IsComplete = $2)", + ct, + title.AsTypedDbParameter(), + isComplete.AsTypedDbParameter()) + is Todo todo + ? TypedResults.Ok(todo) + : TypedResults.NotFound()) + .WithName("FindTodo"); + + group.MapPost("/", async Task, ValidationProblem>> (Todo todo, NpgsqlDataSource db, CancellationToken ct) => + { + var createdTodo = await db.QuerySingleAsync( + "INSERT INTO Todos(Title, IsComplete) Values($1, $2) RETURNING *", + ct, + todo.Title.AsTypedDbParameter(), + todo.IsComplete.AsTypedDbParameter()); + + return TypedResults.Created($"/todos/{createdTodo?.Id}", createdTodo); + }) + .WithName("CreateTodo"); + + group.MapPut("/{id}", async Task> (int id, Todo inputTodo, NpgsqlDataSource db, CancellationToken ct) => + { + inputTodo.Id = id; + + return await db.ExecuteAsync( + "UPDATE Todos SET Title = $1, IsComplete = $2 WHERE Id = $3", + ct, + inputTodo.Title.AsTypedDbParameter(), + inputTodo.IsComplete.AsTypedDbParameter(), + id.AsTypedDbParameter()) == 1 + ? TypedResults.NoContent() + : TypedResults.NotFound(); + }) + .WithName("UpdateTodo"); + + group.MapPut("/{id}/mark-complete", async Task> (int id, NpgsqlDataSource db, CancellationToken ct) => + await db.ExecuteAsync( + "UPDATE Todos SET IsComplete = true WHERE Id = $1", ct, id.AsTypedDbParameter()) == 1 + ? TypedResults.NoContent() + : TypedResults.NotFound()) + .WithName("MarkComplete"); + + group.MapPut("/{id}/mark-incomplete", async Task> (int id, NpgsqlDataSource db, CancellationToken ct) => + await db.ExecuteAsync( + "UPDATE Todos SET IsComplete = false WHERE Id = $1", ct, id.AsTypedDbParameter()) == 1 + ? TypedResults.NoContent() + : TypedResults.NotFound()) + .WithName("MarkIncomplete"); + + group.MapDelete("/{id}", async Task> (int id, NpgsqlDataSource db, CancellationToken ct) => + await db.ExecuteAsync( + "DELETE FROM Todos WHERE Id = $1", ct, id.AsTypedDbParameter()) == 1 + ? TypedResults.NoContent() + : TypedResults.NotFound()) + .WithName("DeleteTodo"); + + group.MapDelete("/delete-all", async (NpgsqlDataSource db, CancellationToken ct) => + TypedResults.Ok(await db.ExecuteAsync("DELETE FROM Todos", ct))) + .WithName("DeleteAll") + .RequireAuthorization(policy => policy.RequireAuthenticatedUser().RequireRole("admin")); + + return group; + } +} + +[JsonSerializable(typeof(Todo))] +[JsonSerializable(typeof(IAsyncEnumerable))] +[JsonSerializable(typeof(IEnumerable))] +internal partial class TodoApiJsonSerializerContext : JsonSerializerContext +{ + +} diff --git a/src/BenchmarksApps/TodosApi/TodosApi.csproj b/src/BenchmarksApps/TodosApi/TodosApi.csproj new file mode 100644 index 000000000..649fb1d8e --- /dev/null +++ b/src/BenchmarksApps/TodosApi/TodosApi.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + false + true + b8ffb8d3-b768-460b-ac1f-ef267c954c85 + false + true + $(DefineConstants);ENABLE_LOGGING + + + + + + + diff --git a/src/BenchmarksApps/TodosApi/appsettings.Development.json b/src/BenchmarksApps/TodosApi/appsettings.Development.json new file mode 100644 index 000000000..dc89e4ec9 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Debug" + } + } +} \ No newline at end of file diff --git a/src/BenchmarksApps/TodosApi/appsettings.json b/src/BenchmarksApps/TodosApi/appsettings.json new file mode 100644 index 000000000..6b6ae8970 --- /dev/null +++ b/src/BenchmarksApps/TodosApi/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Schemes": { + "Bearer": { + "ValidAudiences": [ + "http://localhost:5054", + "http://localhost:5000" + ], + "ValidIssuer": "dotnet-user-jwts" + } + } + } +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 346650d63..4788ae7c5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -12,6 +12,10 @@ false + + + Debug;Release;Debug_Database;Release_Database + true