From 7702c8d758b86881c8a55ecdb76e886e50c6b40d Mon Sep 17 00:00:00 2001 From: Erwin van der Valk Date: Tue, 24 Jun 2025 15:35:15 +0200 Subject: [PATCH] First stab at perf tests. (#2070) * First stab at perf tests. * Update bff/hosts/Hosts.IdentityServer/Config.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * reading values from env vars * dotnet format * cleanup --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 4 +- Directory.Packages.props | 6 +- bff/bff.slnf | 2 + bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj | 4 +- bff/hosts/Hosts.AppHost/Program.cs | 11 +- bff/hosts/Hosts.Bff.Performance/Config.cs | 121 +++++++++ .../DefaultOpenIdConfiguration.cs | 30 +++ .../Hosts.Bff.Performance.csproj | 19 ++ bff/hosts/Hosts.Bff.Performance/Program.cs | 33 +++ .../Properties/launchSettings.json | 17 ++ .../Services/ApiHostedService.cs | 28 ++ .../Services/ApiSettings.cs | 10 + .../Services/BffService.cs | 85 ++++++ .../Services/BffServiceSettings.cs | 11 + .../Services/BffSettings.cs | 10 + .../Services/IdentityServerService.cs | 123 +++++++++ .../Services/IdentityServerSettings.cs | 9 + .../Services/MultiFrontendBffService.cs | 24 ++ .../Services/SingleFrontendBffService.cs | 20 ++ .../appsettings.Development.json | 8 + .../Hosts.Bff.Performance/appsettings.json | 9 + bff/hosts/Hosts.IdentityServer/Config.cs | 249 +++++------------- .../Hosts.ServiceDefaults/AppHostServices.cs | 7 +- .../Bff.Performance/Bff.Performance.csproj | 18 ++ bff/performance/Bff.Performance/Program.cs | 53 ++++ .../Properties/launchSettings.json | 11 + .../Bff.Performance/Scenarios/BaseScenario.cs | 45 ++++ .../Scenarios/Bff/BffScenarios.cs | 18 ++ .../Scenarios/Bff/CallAnonymousLocalApi.cs | 33 +++ bff/performance/Bff.Performance/TestClient.cs | 124 +++++++++ .../TestInfra/AutoFollowRedirectHandler.cs | 43 +++ .../TestInfra/CloningHttpMessageHandler.cs | 81 ++++++ .../TestInfra/CookieHandler.cs | 43 +++ .../TestInfra/RequestLoggingHandler.cs | 54 ++++ products.slnx | 2 + 35 files changed, 1172 insertions(+), 193 deletions(-) create mode 100644 bff/hosts/Hosts.Bff.Performance/Config.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj create mode 100644 bff/hosts/Hosts.Bff.Performance/Program.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/BffService.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs create mode 100644 bff/hosts/Hosts.Bff.Performance/appsettings.Development.json create mode 100644 bff/hosts/Hosts.Bff.Performance/appsettings.json create mode 100644 bff/performance/Bff.Performance/Bff.Performance.csproj create mode 100644 bff/performance/Bff.Performance/Program.cs create mode 100644 bff/performance/Bff.Performance/Properties/launchSettings.json create mode 100644 bff/performance/Bff.Performance/Scenarios/BaseScenario.cs create mode 100644 bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs create mode 100644 bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs create mode 100644 bff/performance/Bff.Performance/TestClient.cs create mode 100644 bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs create mode 100644 bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs create mode 100644 bff/performance/Bff.Performance/TestInfra/CookieHandler.cs create mode 100644 bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs diff --git a/.gitignore b/.gitignore index 86aecdff9..4918295c7 100644 --- a/.gitignore +++ b/.gitignore @@ -226,4 +226,6 @@ artifacts #exclude for verify.tests *.received.txt -*.Artifacts/ \ No newline at end of file +*.Artifacts/ + +reports \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 4b9f970c6..4c32bd041 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,11 +86,15 @@ + + + + @@ -135,4 +139,4 @@ - + \ No newline at end of file diff --git a/bff/bff.slnf b/bff/bff.slnf index 019f529f9..f695161cd 100644 --- a/bff/bff.slnf +++ b/bff/bff.slnf @@ -3,6 +3,7 @@ "path": "..\\products.slnx", "projects": [ "bff\\performance\\Bff.Benchmarks\\Bff.Benchmarks.csproj", + "bff\\performance\\Bff.Performance\\Bff.Performance.csproj", ".github\\workflow-gen\\workflow-gen.csproj", "bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent.Client\\Hosts.Bff.Blazor.PerComponent.Client.csproj", "bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent\\Hosts.Bff.Blazor.PerComponent.csproj", @@ -12,6 +13,7 @@ "bff\\hosts\\Hosts.Bff.DPoP\\Hosts.Bff.DPoP.csproj", "bff\\hosts\\Hosts.Bff.EF\\Hosts.Bff.EF.csproj", "bff\\hosts\\Hosts.Bff.InMemory\\Hosts.Bff.InMemory.csproj", + "bff\\hosts\\Hosts.Bff.Performance\\Hosts.Bff.Performance.csproj", "bff\\hosts\\Hosts.Bff.MultiFrontend\\Hosts.Bff.MultiFrontend.csproj", "bff\\hosts\\Hosts.IdentityServer\\Hosts.IdentityServer.csproj", "bff\\hosts\\Hosts.ServiceDefaults\\Hosts.ServiceDefaults.csproj", diff --git a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj index d12abcbcb..36272dabc 100644 --- a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj +++ b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj @@ -17,9 +17,6 @@ - - - @@ -27,6 +24,7 @@ + diff --git a/bff/hosts/Hosts.AppHost/Program.cs b/bff/hosts/Hosts.AppHost/Program.cs index 7d9a04ea9..a047cf865 100644 --- a/bff/hosts/Hosts.AppHost/Program.cs +++ b/bff/hosts/Hosts.AppHost/Program.cs @@ -18,6 +18,14 @@ var bff = builder.AddProject(AppHostServices.Bff) .WithAwaitedReference(api) ; +var perf = builder.AddProject(AppHostServices.BffPerf) + .WithExternalHttpEndpoints() + .WithEndpoint(6100, isProxied: false, scheme: "https", name: "idsrv") + .WithEndpoint(6001, isProxied: false, scheme: "https", name: "api") + .WithEndpoint(6002, isProxied: false, scheme: "https", name: "single") + .WithEndpoint(6003, isProxied: false, scheme: "https", name: "multi") + ; + var bffMulti = builder.AddProject(AppHostServices.BffMultiFrontend) .WithExternalHttpEndpoints() .WithUrl("https://app1.localhost:5005", "https://app1.localhost:5005") @@ -58,6 +66,7 @@ builder.AddProject(AppHostServices.Migrations); idServer .WithReference(bff) + .WithReference(perf) .WithReference(bffMulti) .WithReference(bffEf) .WithReference(bffBlazorPerComponent) @@ -74,8 +83,6 @@ builder.AddProject(AppHostServices.TemplateBffRemote, launchProfil builder.AddProject(AppHostServices.TemplateBffBlazor); - - builder.Build().Run(); public static class Extensions diff --git a/bff/hosts/Hosts.Bff.Performance/Config.cs b/bff/hosts/Hosts.Bff.Performance/Config.cs new file mode 100644 index 000000000..feeb66966 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Config.cs @@ -0,0 +1,121 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Hosts.ServiceDefaults; + +namespace IdentityServer; + +public static class Config +{ + public static IEnumerable IdentityResources => + [ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + ]; + + public static IEnumerable ApiScopes => + [ + new("api", ["name"]), + new("scope-for-isolated-api", ["name"]), + ]; + + public static IEnumerable ApiResources => + [ + new("urn:isolated-api", "isolated api") + { + RequireResourceIndicator = true, + Scopes = { "scope-for-isolated-api" } + } + ]; + // Get the BFF URL from the service discovery system. Then use this for building the redirect urls etc.. + private static Uri bffUrl = ServiceDiscovery.ResolveService(AppHostServices.Bff); + private static Uri bffMultiFrontendUrl = ServiceDiscovery.ResolveService(AppHostServices.BffMultiFrontend); + private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop); + private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf); + private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent); + private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable Clients => + [ + BuildClient("bff.perf", + ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"), + ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"), + new Uri("https://app1.localhost:6002") + ), + + BuildClient("bff", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffUrl), + + BuildClient("bff.multi-frontend.default", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffMultiFrontendUrl), + + BuildClient("bff.multi-frontend.config", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri(bffMultiFrontendUrl, "from-config")), + + BuildClient("bff.multi-frontend.with-path", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri(bffMultiFrontendUrl, "with-path")), + + BuildClient("bff.multi-frontend.with-domain", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri("https://app1.localhost:5005")), + + BuildClient("bff.dpop", client => + { + client.RequireDPoP = true; + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffDPopUrl), + + BuildClient("bff.ef", client => + { + client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel"; + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffEfUrl), + + BuildClient("blazor", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035")) + ]; + + + private static Client BuildClient(string clientId, Action postConfigure, params Uri[] uris) + { + var client = BuildClient(clientId, uris); + postConfigure(client); + return client; + } + + private static Client BuildClient(string clientId, params Uri[] uris) => new Client + { + ClientId = clientId, + ClientSecrets = { new Secret("secret".Sha256()) }, + + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(), + FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(), + PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(), + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api" }, + + RefreshTokenExpiration = TokenExpiration.Absolute, + AbsoluteRefreshTokenLifetime = 60, + AccessTokenLifetime = 15 // Force refresh + }; + +} diff --git a/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs b/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs new file mode 100644 index 000000000..d2c576363 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/DefaultOpenIdConfiguration.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Hosts.Bff.Performance.Services; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; + +public static class DefaultOpenIdConfiguration +{ + public static void Apply(OpenIdConnectOptions options, BffSettings settings) + { + options.Authority = settings.IdentityServerUrl.ToString(); + + // confidential client using code flow + PKCE + options.ClientId = "bff.perf"; + options.ClientSecret = "secret"; + options.ResponseType = "code"; + options.ResponseMode = "query"; + + options.MapInboundClaims = false; + options.GetClaimsFromUserInfoEndpoint = true; + options.SaveTokens = true; + + // request scopes + refresh tokens + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("api"); + options.Scope.Add("offline_access"); + } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj b/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj new file mode 100644 index 000000000..ebc2f57de --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/bff/hosts/Hosts.Bff.Performance/Program.cs b/bff/hosts/Hosts.Bff.Performance/Program.cs new file mode 100644 index 000000000..1528158b1 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Program.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Hosts.Bff.Performance.Services; + +var builder = Host.CreateApplicationBuilder(); + +builder.Services.Configure(builder.Configuration); +builder.Services.Configure(builder.Configuration); +builder.Services.Configure(builder.Configuration); + +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +// Add services to the container. + +var app = builder.Build(); + +// Configure the HTTP request pipeline. + +// spin up multiple applications: +// Plain yarp + + +// single frontend +// multi-frontend +// bff with server side EF sessions + + + + +app.Run(); diff --git a/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json b/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json new file mode 100644 index 000000000..56b0bb23e --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Hosts.Bff.Performance": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "ApiUrl": "https://localhost:6100", + "IdentityServerUrl": "https://localhost:6001", + "BffUrl1": "https://localhost:6002", + "BffUrl2": "https://localhost:6003", + "BffUrl3": "https://app1.localhost:6003" + }, + "applicationUrl": "https://localhost:6100;https://localhost:6001;https://localhost:6002;https://localhost:6003;https://app1.localhost:6003" + } + } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs b/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs new file mode 100644 index 000000000..877cb67cc --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/ApiHostedService.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Options; + +namespace Hosts.Bff.Performance.Services; + +public class ApiHostedService(IOptions apiSettings) : BackgroundService +{ + public ApiSettings Settings { get; } = apiSettings.Value; + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + var builder = WebApplication.CreateBuilder(); + builder.AddServiceDefaults(); + + // Configure Kestrel to listen on the specified Uri + builder.WebHost.UseUrls(Settings.ApiUrl.ToString()); + var app = builder.Build(); + + + app.UseRouting(); + + app.MapGet("/", () => "ok"); + return app.RunAsync(stoppingToken); + + } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs new file mode 100644 index 000000000..e6f3e922e --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/ApiSettings.cs @@ -0,0 +1,10 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Hosts.Bff.Performance.Services; + +public class ApiSettings +{ + public required Uri ApiUrl { get; set; } + public required Uri IdentityServerUrl { get; set; } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs new file mode 100644 index 000000000..512bc45d7 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/BffService.cs @@ -0,0 +1,85 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Bff; +using Duende.Bff.AccessTokenManagement; +using Duende.Bff.Yarp; +using Microsoft.Extensions.Options; + +namespace Hosts.Bff.Performance.Services; + +public abstract class BffService(string[] urlConfigKeys, IConfiguration config, IOptions bffSettings) : BackgroundService +{ + public IConfiguration Config { get; } = config; + public BffSettings Settings { get; } = bffSettings.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var urls = urlConfigKeys + .Select(x => Config[x]) + .OfType() + .ToArray(); + + var builder = WebApplication.CreateBuilder(); + builder.AddServiceDefaults(); + // Configure Kestrel to listen on the specified Uri + builder.WebHost.UseUrls(urls); + + builder.Services.AddAuthorization(); + ConfigureServices(builder.Services); + + var bffBuilder = builder.Services.AddBff() + .AddRemoteApis(); + + ConfigureBff(bffBuilder); + + // Build and run the web app + var app = builder.Build(); + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseBff(); + + ConfigureApp(app); + + app.MapGet("/local_anon", () => DateTime.Now.ToString("s")) + .AsBffApiEndpoint() + .AllowAnonymous(); + + app.MapGet("/local", () => DateTime.Now.ToString("s")) + .RequireAuthorization() + .AsBffApiEndpoint(); + + app.MapRemoteBffApiEndpoint("/remote_anon", Settings.ApiUrl) + .WithAccessToken(RequiredTokenType.None); + + + app.MapRemoteBffApiEndpoint("/remote_user", Settings.ApiUrl) + .WithAccessToken(); + + app.MapRemoteBffApiEndpoint("/remote_client", Settings.ApiUrl) + .WithAccessToken(RequiredTokenType.Client); + + // Todo: Make sure this is mapped implicitly + app.MapBffManagementEndpoints(); + + await app.RunAsync(stoppingToken); + } + + public virtual void ConfigureServices(IServiceCollection services) + { + } + + public virtual void ConfigureBff(BffBuilder bff) + { + } + + public virtual void ConfigureApp(WebApplication app) + { + } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs new file mode 100644 index 000000000..e58f8fdc6 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/BffServiceSettings.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Hosts.Bff.Performance.Services; + +public class BffServiceSettings +{ + public required string Uri { get; set; } + + public Uri ApiUrl { get; } = new Uri("https://localhost:5999"); +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs new file mode 100644 index 000000000..716e5a9b7 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/BffSettings.cs @@ -0,0 +1,10 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Hosts.Bff.Performance.Services; + +public class BffSettings +{ + public required Uri IdentityServerUrl { get; set; } + public required Uri ApiUrl { get; set; } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs new file mode 100644 index 000000000..67643f7fa --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerService.cs @@ -0,0 +1,123 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Test; +using IdentityServer; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Hosts.Bff.Performance.Services; + +public class IdentityServerService(IOptions settings, IConfiguration config) : BackgroundService +{ + public IdentityServerSettings Settings { get; } = settings.Value; + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + var builder = WebApplication.CreateBuilder(); + builder.AddServiceDefaults(); + // Configure Kestrel to listen on the specified Uri + builder.WebHost.UseUrls(Settings.IdentityServerUrl); + + builder.Services.AddAuthorization(); + builder.Services.AddHttpLogging(); + + var isBuilder = builder.Services.AddIdentityServer(options => + { + options.Events.RaiseErrorEvents = true; + options.Events.RaiseInformationEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseSuccessEvents = true; + + options.EmitStaticAudienceClaim = true; + }) + .AddTestUsers([new TestUser() + { + SubjectId = "bob", + Username = "bob", + Password = "bob", + Claims = [ + new Claim(JwtClaimTypes.Name, "Bob Smith"), + new Claim(JwtClaimTypes.GivenName, "Bob"), + new Claim(JwtClaimTypes.FamilyName, "Smith"), + new Claim(JwtClaimTypes.Email, "bob@duende.com") + ], + IsActive = true + + }]) + ; + + // in-memory, code config + isBuilder.AddInMemoryIdentityResources(Config.IdentityResources); + isBuilder.AddInMemoryApiScopes(Config.ApiScopes); + + var bffUrls = config.AsEnumerable() + .Where(x => x.Key.StartsWith("BffUrl")) + .Select(x => x.Value) + .OfType(); + + isBuilder.AddInMemoryClients([new Client + { + ClientId = "bff.perf", + ClientSecrets = [new Secret("secret".Sha256())], + RedirectUris = bffUrls.Select(x => x.TrimEnd('/') + "/signin-oidc").ToArray(), + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api" }, + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + + RefreshTokenExpiration = TokenExpiration.Absolute, + AbsoluteRefreshTokenLifetime = 60, + AccessTokenLifetime = 15 // Force refresh + + }]); + isBuilder.AddInMemoryApiResources(Config.ApiResources); + + var app = builder.Build(); + + app.UseHttpLogging(); + app.UseDeveloperExceptionPage(); + app.UseStaticFiles(); + + app.UseRouting(); + app.UseIdentityServer(); + app.UseAuthorization(); + + app.MapGet("/", () => "identity server"); + + app.MapGet("/account/login", async ctx => + { + var props = new AuthenticationProperties(); + await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "bob"), + new Claim(JwtClaimTypes.Name, "bob") + ], + "login", "name", "role")), props); + }); + + app.MapGet("/account/logout", async ctx => + { + // signout as if the user were prompted + await ctx.SignOutAsync(); + + var logoutId = ctx.Request.Query["logoutId"]; + var interaction = ctx.RequestServices.GetRequiredService(); + + var signOutContext = await interaction.GetLogoutContextAsync(logoutId); + + ctx.Response.Redirect(signOutContext.PostLogoutRedirectUri ?? "/"); + }); + + return app.RunAsync(stoppingToken); + } + +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs new file mode 100644 index 000000000..ba8689db1 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/IdentityServerSettings.cs @@ -0,0 +1,9 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Hosts.Bff.Performance.Services; + +public class IdentityServerSettings +{ + public required string IdentityServerUrl { get; set; } +} diff --git a/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs new file mode 100644 index 000000000..b600a5a0e --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/MultiFrontendBffService.cs @@ -0,0 +1,24 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Bff; +using Duende.Bff.DynamicFrontends; +using Microsoft.Extensions.Options; + +namespace Hosts.Bff.Performance.Services; + +public class MultiFrontendBffService(IConfiguration config, IOptions settings) : BffService(["BffUrl2", "BffUrl3"], config, settings) +{ + public override void ConfigureServices(IServiceCollection services) + { + } + + public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings)) + .AddFrontends(new BffFrontend(BffFrontendName.Parse("default"))) + + // Note, in order for this to work, we'll need to inject this as config + .AddFrontends(new BffFrontend(BffFrontendName.Parse("app1")).MappedToOrigin(Config.GetValue("BffUrl3") ?? throw new InvalidOperationException("BFFUrl3 is null"))); + + public override void ConfigureApp(WebApplication app) => app.MapGet("/", (SelectedFrontend selectedFrontend) => "multi - " + selectedFrontend.Get().Name); +} + diff --git a/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs b/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs new file mode 100644 index 000000000..a7c43f768 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/Services/SingleFrontendBffService.cs @@ -0,0 +1,20 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Bff; +using Duende.Bff.DynamicFrontends; +using Microsoft.Extensions.Options; + +namespace Hosts.Bff.Performance.Services; + +public class SingleFrontendBffService(IConfiguration config, IOptions settings) : BffService(["BffUrl1"], config, settings) +{ + public override void ConfigureServices(IServiceCollection services) + { + } + + public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings)) + .AddFrontends(new BffFrontend(BffFrontendName.Parse("default"))); + + public override void ConfigureApp(WebApplication app) => app.MapGet("/", () => "single"); +} diff --git a/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json b/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/bff/hosts/Hosts.Bff.Performance/appsettings.json b/bff/hosts/Hosts.Bff.Performance/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/bff/hosts/Hosts.Bff.Performance/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/bff/hosts/Hosts.IdentityServer/Config.cs b/bff/hosts/Hosts.IdentityServer/Config.cs index 248f5574e..feeb66966 100644 --- a/bff/hosts/Hosts.IdentityServer/Config.cs +++ b/bff/hosts/Hosts.IdentityServer/Config.cs @@ -36,203 +36,86 @@ public static class Config private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop); private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf); private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent); - private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); - - public static IEnumerable Clients => + private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable Clients => [ - new Client - { - ClientId = "bff", - ClientSecrets = { new Secret("secret".Sha256()) }, + BuildClient("bff.perf", + ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"), + ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"), + new Uri("https://app1.localhost:6002") + ), - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, + BuildClient("bff", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffUrl), - RedirectUris = { $"{bffUrl}signin-oidc" }, - FrontChannelLogoutUri = $"{bffUrl}signout-oidc", - PostLogoutRedirectUris = { $"{bffUrl}signout-callback-oidc" }, + BuildClient("bff.multi-frontend.default", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffMultiFrontendUrl), - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + BuildClient("bff.multi-frontend.config", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri(bffMultiFrontendUrl, "from-config")), - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60, - AccessTokenLifetime = 15 // Force refresh - }, - new Client - { - ClientId = "bff.multi-frontend.default", - ClientSecrets = { new Secret("secret".Sha256()) }, + BuildClient("bff.multi-frontend.with-path", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri(bffMultiFrontendUrl, "with-path")), - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { $"{bffMultiFrontendUrl}signin-oidc" }, - FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc", - PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" }, + BuildClient("bff.multi-frontend.with-domain", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, new Uri("https://app1.localhost:5005")), - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + BuildClient("bff.dpop", client => + { + client.RequireDPoP = true; + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffDPopUrl), - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60, - AccessTokenLifetime = 15 // Force refresh - }, - new Client - { - ClientId = "bff.multi-frontend.config", - ClientSecrets = { new Secret("secret".Sha256()) }, + BuildClient("bff.ef", client => + { + client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel"; + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffEfUrl), - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { $"{bffMultiFrontendUrl}from-config/signin-oidc" }, - FrontChannelLogoutUri = $"{bffMultiFrontendUrl}from-config/signout-oidc", - PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}from-config/signout-callback-oidc" }, + BuildClient("blazor", client => + { + client.AllowedScopes.Add("scope-for-isolated-api"); + }, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035")) + ]; - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60, - AccessTokenLifetime = 15 // Force refresh - }, - new Client - { - ClientId = "bff.multi-frontend.with-path", - ClientSecrets = { new Secret("secret".Sha256()) }, + private static Client BuildClient(string clientId, Action postConfigure, params Uri[] uris) + { + var client = BuildClient(clientId, uris); + postConfigure(client); + return client; + } - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { $"{bffMultiFrontendUrl}with-path/signin-oidc" }, - FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc", - PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" }, + private static Client BuildClient(string clientId, params Uri[] uris) => new Client + { + ClientId = clientId, + ClientSecrets = { new Secret("secret".Sha256()) }, - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, + AllowedGrantTypes = + { + GrantType.AuthorizationCode, + GrantType.ClientCredentials, + OidcConstants.GrantTypes.TokenExchange + }, + RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(), + FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(), + PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(), - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60, - AccessTokenLifetime = 15 // Force refresh - }, - new Client - { - ClientId = "bff.multi-frontend.with-domain", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { $"https://app1.localhost:5005/signin-oidc" }, - FrontChannelLogoutUri = $"https://app1.localhost:5005/signout-oidc", - PostLogoutRedirectUris = { $"https://app1.localhost:5005/signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60, - AccessTokenLifetime = 15 // Force refresh - }, - new Client - { - ClientId = "bff.dpop", - ClientSecrets = { new Secret("secret".Sha256()) }, - RequireDPoP = true, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - - RedirectUris = { $"{bffDPopUrl}signin-oidc" }, - FrontChannelLogoutUri = $"{bffDPopUrl}signout-oidc", - PostLogoutRedirectUris = { $"{bffDPopUrl}signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - // Intentionally set lifetime short to see what happens when access and refresh tokens expire - AccessTokenLifetime = 15, - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60 - }, - new Client - { - ClientId = "bff.ef", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - RedirectUris = { $"{bffEfUrl}signin-oidc" }, - FrontChannelLogoutUri = $"{bffEfUrl}signout-oidc", - BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel", - PostLogoutRedirectUris = { $"{bffEfUrl}signout-callback-oidc" }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - // Intentionally set lifetime short to see what happens when access and refresh tokens expire - AccessTokenLifetime = 15, - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60 - }, - - new Client - { - ClientId = "blazor", - ClientSecrets = { new Secret("secret".Sha256()) }, - - AllowedGrantTypes = - { - GrantType.AuthorizationCode, - GrantType.ClientCredentials, - OidcConstants.GrantTypes.TokenExchange - }, - - RedirectUris = - { - $"{bffBlazorWebAssemblyUrl}signin-oidc", - $"{bffBlazorPerComponentUrl}signin-oidc", - "https://localhost:7035/signin-oidc" - }, - PostLogoutRedirectUris = - { - $"{bffBlazorWebAssemblyUrl}signout-callback-oidc", $"{bffBlazorPerComponentUrl}signout-callback-oidc" - }, - - AllowOfflineAccess = true, - AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" }, - - // Intentionally set lifetime short to see what happens when access and refresh tokens expire - AccessTokenLifetime = 15, - RefreshTokenExpiration = TokenExpiration.Absolute, - AbsoluteRefreshTokenLifetime = 60 - } - ]; + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "api" }, + RefreshTokenExpiration = TokenExpiration.Absolute, + AbsoluteRefreshTokenLifetime = 60, + AccessTokenLifetime = 15 // Force refresh + }; } diff --git a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs index beaebf93d..f01ac7edc 100644 --- a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs +++ b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs @@ -4,6 +4,7 @@ namespace Hosts.ServiceDefaults; public static class AppHostServices { + public const string BffPerf = "bff-perf"; public const string IdentityServer = "identity-server"; public const string Api = "api"; public const string IsolatedApi = "api-isolated"; @@ -24,6 +25,7 @@ public static class AppHostServices Api, IsolatedApi, Bff, + BffPerf, BffEf, BffBlazorWebassembly, BffBlazorPerComponent, @@ -39,13 +41,12 @@ public static class AppHostServices public static class ServiceDiscovery { - public static Uri ResolveService(string serviceName) + public static Uri ResolveService(string serviceName, string appName = "https") { - var scheme = "https"; var host = serviceName; // Compose the environment variable key - var envVarKey = $"services__{host}__{scheme}__0"; + var envVarKey = $"services__{host}__{appName}__0"; // Try to get the value from environment variables var value = Environment.GetEnvironmentVariable(envVarKey); diff --git a/bff/performance/Bff.Performance/Bff.Performance.csproj b/bff/performance/Bff.Performance/Bff.Performance.csproj new file mode 100644 index 000000000..ec46548a3 --- /dev/null +++ b/bff/performance/Bff.Performance/Bff.Performance.csproj @@ -0,0 +1,18 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + diff --git a/bff/performance/Bff.Performance/Program.cs b/bff/performance/Bff.Performance/Program.cs new file mode 100644 index 000000000..9cad1295c --- /dev/null +++ b/bff/performance/Bff.Performance/Program.cs @@ -0,0 +1,53 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// full login / logout flow + +// Api calls: + +// plain yarp call +// Local api +// multi-frontend index.html response +// remote api user token +// - yarp +// - plain +// - single multi-frontend +// - 100 multi-frontends +// - server side sessions +// - large response payload + +// remote api client credentials token +// - yarp +// - plain +// - single multi-frontend +// - 100 multi-frontends +// - large response payload + +// with distributed cache + short lifecycle + +using Bff.Performance.Scenarios.Bff; +using Microsoft.Extensions.Configuration; +using NBomber.Contracts.Stats; +using NBomber.CSharp; +using NBomber.Http; + +var config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + +var urls = config.GetSection("BffUrls").Get(); + +if (urls == null || urls.Length == 0) +{ + throw new InvalidOperationException("BffUrls configuration is missing or empty."); +} +NBomberRunner + .RegisterScenarios(new BffScenarios(urls).Scenarios) + .WithWorkerPlugins(new HttpMetricsPlugin()) + .WithReportingInterval(TimeSpan.FromSeconds(5)) + .WithReportFormats( + ReportFormat.Csv, ReportFormat.Html + ) + .Run(args); diff --git a/bff/performance/Bff.Performance/Properties/launchSettings.json b/bff/performance/Bff.Performance/Properties/launchSettings.json new file mode 100644 index 000000000..35a4b8e5e --- /dev/null +++ b/bff/performance/Bff.Performance/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Bff.Performance": { + "commandName": "Project", + "environmentVariables": { + "BffUrls__0": "https://localhost:6002", + "BffUrls__1": "https://localhost:6003" + } + } + } +} diff --git a/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs b/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs new file mode 100644 index 000000000..3ef589e16 --- /dev/null +++ b/bff/performance/Bff.Performance/Scenarios/BaseScenario.cs @@ -0,0 +1,45 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using NBomber.Contracts; +using NBomber.CSharp; + + + +namespace Bff.Performance.Scenarios; + +public abstract class BaseScenario(string name) +{ + public virtual Task Init(IScenarioInitContext c) => Task.CompletedTask; + public string Name => GetType().Name + "_" + name; + + public HttpStatusCode SuccessStatusCode { get; set; } = HttpStatusCode.OK; + + public async Task Run(IScenarioContext context) + { + var result = await RunScenario(context); + + if (result.StatusCode == SuccessStatusCode) + { + return Response.Ok(result.StatusCode, result.StatusCode.ToString(), result.Content.Headers.ContentLength ?? 0); + } + return Response.Fail(result.StatusCode, result.StatusCode.ToString(), "Returned an unexpected httpresult", result.Content.Headers.ContentLength ?? 0); + } + + public abstract Task RunScenario(IScenarioContext context); + + public TestClient Client { get; set; } = null!; + + public static implicit operator ScenarioProps(BaseScenario scenario) => Scenario.Create(scenario.Name, scenario.Run) + .WithoutWarmUp() + .WithLoadSimulations( + Simulation.Inject(rate: 10, + interval: TimeSpan.FromSeconds(1), + during: TimeSpan.FromSeconds(30)) + ) + .WithInit(scenario.Init); +} + + + diff --git a/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs b/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs new file mode 100644 index 000000000..a92ae908c --- /dev/null +++ b/bff/performance/Bff.Performance/Scenarios/Bff/BffScenarios.cs @@ -0,0 +1,18 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using NBomber.Contracts; + +namespace Bff.Performance.Scenarios.Bff; + +public class BffScenarios +{ + public ScenarioProps[] Scenarios; + + public BffScenarios(Uri[] baseUris) => Scenarios = baseUris.SelectMany(x => new ScenarioProps[] + { + new CallAnonymousLocalApi(x), + new CallAuthorizedLocalApi(x) + }) + .ToArray(); +} diff --git a/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs b/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs new file mode 100644 index 000000000..521433594 --- /dev/null +++ b/bff/performance/Bff.Performance/Scenarios/Bff/CallAnonymousLocalApi.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using NBomber.Contracts; + +namespace Bff.Performance.Scenarios.Bff; + +public class CallAnonymousLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString()) +{ + public override Task Init(IScenarioInitContext c) + { + Client = TestClient.Create(baseUri); + return Task.CompletedTask; + } + + public override async Task RunScenario(IScenarioContext context) => await Client.GetAsync("/local_anon"); +} + +public class CallAuthorizedLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString()) +{ + public override async Task Init(IScenarioInitContext c) + { + Client = TestClient.Create(baseUri); + await Client.TriggerLogin(); + } + + public override async Task RunScenario(IScenarioContext context) + { + var result = await Client.GetAsync("/local"); + + return result; + } +} diff --git a/bff/performance/Bff.Performance/TestClient.cs b/bff/performance/Bff.Performance/TestClient.cs new file mode 100644 index 000000000..227f989ff --- /dev/null +++ b/bff/performance/Bff.Performance/TestClient.cs @@ -0,0 +1,124 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bff.Performance.TestInfra; + +namespace Bff.Performance; + +public class TestClient(Uri baseAddress, CookieHandler cookies, HttpMessageHandler handler) +{ + + public HttpClient Client = new(handler) + { + BaseAddress = baseAddress + }; + public void ClearCookies() => cookies.ClearCookies(); + + public static TestClient Create(Uri baseAddress, CookieContainer? cookies = null) + { + var inner = new SocketsHttpHandler + { + // We need to disable cookies and follow redirects + // because we do this manually (see below). + UseCookies = false, + AllowAutoRedirect = false + }; + + var cookieHandler = new CookieHandler(inner, cookies); + var handler = new AutoFollowRedirectHandler((_) => { }) + { + InnerHandler = cookieHandler + }; + + return new TestClient(baseAddress, cookieHandler, handler); + } + + public Task GetAsync(string path, Dictionary? headers = null, CancellationToken ct = default) + { + var request = BuildRequest(HttpMethod.Get, path, headers); + + return Client.SendAsync(request); + } + + public Task PostAsync(string path, object? body, Dictionary? headers = null, CancellationToken ct = default) + { + var request = BuildRequest(HttpMethod.Post, path, headers); + request.Content = JsonContent.Create(body); + + return Client.SendAsync(request); + } + + public Task PostAsync(Uri path, T body, Dictionary? headers = null, CancellationToken ct = default) + where T : HttpContent + { + var request = BuildRequest(HttpMethod.Post, path.ToString(), headers); + request.Content = body; + + return Client.SendAsync(request); + } + + private static HttpRequestMessage BuildRequest(HttpMethod httpMethod, string path, + Dictionary? headers = null) + { + var request = new HttpRequestMessage(httpMethod, path); + + if (headers == null) + { + request.Headers.Add("x-csrf", "1"); + } + else + { + foreach (var header in headers) + { + request.Headers.Add(header.Key, header.Value); + } + } + + return request; + } + + + public async Task TriggerLogin(string userName = "alice", string password = "alice", CancellationToken ct = default) => await GetAsync("/bff/login"); + + public async Task TriggerLogout() + { + // To trigger a logout, we need the logout claim + var userClaims = await GetUserClaims(); + + var logoutLink = userClaims.FirstOrDefault(x => x.Type == "bff:logout_url") + ?? throw new InvalidOperationException("Failed to find logout link claim"); + + return await GetAsync(logoutLink.Value.ToString()!); + } + + public async Task GetUserClaims() + { + var userClaimsString = await GetStringAsync("/bff/user"); + var userClaims = JsonSerializer.Deserialize(userClaimsString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + })!; + return userClaims; + } + + private async Task GetStringAsync(string path) + { + var response = await GetAsync(path); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"Failed to get string from {path}. Status code: {response.StatusCode}"); + } + return await response.Content.ReadAsStringAsync(); + } + + public async Task InvokeApi(string url) => await GetAsync(url); + + public record UserClaim + { + public required string Type { get; init; } + public required object Value { get; init; } + } +} diff --git a/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs b/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs new file mode 100644 index 000000000..f46f50c42 --- /dev/null +++ b/bff/performance/Bff.Performance/TestInfra/AutoFollowRedirectHandler.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; + +namespace Bff.Performance.TestInfra; + +public class AutoFollowRedirectHandler(Action writeOutput) : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var previousUri = request.RequestUri; + for (var i = 0; i < 20; i++) + { + var result = await base.SendAsync(request, cancellationToken); + if ((result.StatusCode == HttpStatusCode.Found || result.StatusCode == HttpStatusCode.RedirectKeepVerb) && result.Headers.Location != null) + { + writeOutput($"Redirecting from {previousUri} to {result.Headers.Location}"); + + var newUri = result.Headers.Location; + if (!newUri.IsAbsoluteUri) + { + newUri = new Uri(previousUri!, newUri); + } + + var headers = request.Headers; + request = new HttpRequestMessage(HttpMethod.Get, newUri); + foreach (var header in headers) + { + request.Headers.Add(header.Key, header.Value); + } + + previousUri = request.RequestUri; + continue; + } + + return result; + } + + throw new InvalidOperationException("Keeps redirecting forever"); + } +} diff --git a/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs b/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs new file mode 100644 index 000000000..0d808f3e4 --- /dev/null +++ b/bff/performance/Bff.Performance/TestInfra/CloningHttpMessageHandler.cs @@ -0,0 +1,81 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Bff.Performance.TestInfra; + +public class CloningHttpMessageHandler(HttpClient innerHttpClient) : HttpMessageHandler +{ + private readonly HttpClient _innerHttpClient = + innerHttpClient ?? throw new ArgumentNullException(nameof(innerHttpClient)); + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Clone the incoming request + var clonedRequest = await CloneHttpRequestMessageAsync(request); + + // Send the cloned request using the inner HttpClient + var response = await _innerHttpClient.SendAsync(clonedRequest, cancellationToken); + + // Clone the response and return it + return await CloneHttpResponseMessageAsync(response); + } + + private async Task CloneHttpRequestMessageAsync(HttpRequestMessage original) + { + var cloned = new HttpRequestMessage(original.Method, original.RequestUri) + { + Version = original.Version + }; + + // Copy the content if present + if (original.Content != null) + { + //var memoryStream = new MemoryStream(); + //await original.Content.CopyToAsync(memoryStream); + //memoryStream.Position = 0; + //cloned.Content = new StreamContent(memoryStream); + cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync()); + + // Copy headers from the original content to the cloned content + foreach (var header in original.Content.Headers) + { + cloned.Content.Headers.Add(header.Key, header.Value); + } + } + + // Copy headers + foreach (var header in original.Headers) + { + cloned.Headers.Add(header.Key, header.Value); + } + + return cloned; + } + + private async Task CloneHttpResponseMessageAsync(HttpResponseMessage original) + { + var cloned = new HttpResponseMessage(original.StatusCode) + { + Version = original.Version, + ReasonPhrase = original.ReasonPhrase, + RequestMessage = original.RequestMessage + }; + + cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync()); + + // Copy headers from the original content to the cloned content + foreach (var header in original.Content.Headers) + { + cloned.Content.Headers.Add(header.Key, header.Value); + } + + // Copy headers + foreach (var header in original.Headers) + { + cloned.Headers.Add(header.Key, header.Value); + } + + return cloned; + } +} diff --git a/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs b/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs new file mode 100644 index 000000000..ef4912be5 --- /dev/null +++ b/bff/performance/Bff.Performance/TestInfra/CookieHandler.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Microsoft.Net.Http.Headers; + +namespace Bff.Performance.TestInfra; + +public class CookieHandler(HttpMessageHandler innerHandler, CookieContainer? cookies = null) : DelegatingHandler(innerHandler) +{ + public void ClearCookies() => CookieContainer = new CookieContainer(); + public CookieContainer CookieContainer { get; private set; } = cookies ?? new CookieContainer(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var requestUri = request.RequestUri; + var header = CookieContainer.GetCookieHeader(requestUri!); + if (!string.IsNullOrEmpty(header)) + { + request.Headers.Add(HeaderNames.Cookie, header); + } + + var response = await base.SendAsync(request, ct); + + if (response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders)) + { + foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList())) + { + var cookie = new Cookie(cookieHeader.Name.Value!, + cookieHeader.Value.Value, + cookieHeader.Path.Value); + if (cookieHeader.Expires.HasValue) + { + cookie.Expires = cookieHeader.Expires.Value.UtcDateTime; + } + + CookieContainer.Add(requestUri!, cookie); + } + } + + return response; + } +} diff --git a/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs b/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs new file mode 100644 index 000000000..3933808fa --- /dev/null +++ b/bff/performance/Bff.Performance/TestInfra/RequestLoggingHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Bff.Performance.TestInfra; + +public class RequestLoggingHandler( + ILogger log, + Func shouldLog) + : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (!shouldLog(request)) + { + return await base.SendAsync(request, cancellationToken); + } + + var stopwatch = Stopwatch.StartNew(); + try + { + var result = await base.SendAsync(request, cancellationToken); + + log.LogInformation("Executing {method} on {url} returned {statuscode} in {ms} ms", + request.Method, + request.RequestUri, + result.StatusCode, + stopwatch.ElapsedMilliseconds); + + return result; + } + catch (OperationCanceledException) + { + log.LogWarning("Executing {method} on {url} was cancelled in {ms} ms", + request.Method, + request.RequestUri, + stopwatch.ElapsedMilliseconds); + throw; + } + catch (Exception ex) + { + log.LogWarning(ex, + "Exception while executing {method} on {url} in {ms} ms", + request.Method, + request.RequestUri, + stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/products.slnx b/products.slnx index 09749113b..0dd3577a2 100644 --- a/products.slnx +++ b/products.slnx @@ -17,6 +17,7 @@ + @@ -26,6 +27,7 @@ +