diff --git a/src/Alba.sln b/src/Alba.sln index 6fcbff5..37da546 100644 --- a/src/Alba.sln +++ b/src/Alba.sln @@ -22,6 +22,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityServer.New", "Ident EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "..\build\_build.csproj", "{2D104BF1-7D1F-4DFF-9023-AEDF73C34B67}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{C65CC4CF-3D98-46FF-BF2D-D561AEBDD97C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost", "AspireApp\AspireApp.AppHost\AspireApp.AppHost.csproj", "{B66E4CB2-4F6B-4291-BC3A-44DF0474F04A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ServiceDefaults", "AspireApp\AspireApp.ServiceDefaults\AspireApp.ServiceDefaults.csproj", "{5974619B-7169-4E63-A52F-3EF461B17509}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ApiService", "AspireApp\AspireApp.ApiService\AspireApp.ApiService.csproj", "{A775660C-0BC4-4B12-AF9D-41E21D751244}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.Tests", "AspireApp\AspireApp.Tests\AspireApp.Tests.csproj", "{48552EF6-DE98-404F-B7C9-D6D85207ECE8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -68,6 +78,22 @@ Global {B7AB5313-B55C-4AFF-940B-F17DC913AFB6}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7AB5313-B55C-4AFF-940B-F17DC913AFB6}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7AB5313-B55C-4AFF-940B-F17DC913AFB6}.Release|Any CPU.Build.0 = Release|Any CPU + {B66E4CB2-4F6B-4291-BC3A-44DF0474F04A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B66E4CB2-4F6B-4291-BC3A-44DF0474F04A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B66E4CB2-4F6B-4291-BC3A-44DF0474F04A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B66E4CB2-4F6B-4291-BC3A-44DF0474F04A}.Release|Any CPU.Build.0 = Release|Any CPU + {5974619B-7169-4E63-A52F-3EF461B17509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5974619B-7169-4E63-A52F-3EF461B17509}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5974619B-7169-4E63-A52F-3EF461B17509}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5974619B-7169-4E63-A52F-3EF461B17509}.Release|Any CPU.Build.0 = Release|Any CPU + {A775660C-0BC4-4B12-AF9D-41E21D751244}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A775660C-0BC4-4B12-AF9D-41E21D751244}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A775660C-0BC4-4B12-AF9D-41E21D751244}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A775660C-0BC4-4B12-AF9D-41E21D751244}.Release|Any CPU.Build.0 = Release|Any CPU + {48552EF6-DE98-404F-B7C9-D6D85207ECE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48552EF6-DE98-404F-B7C9-D6D85207ECE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48552EF6-DE98-404F-B7C9-D6D85207ECE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48552EF6-DE98-404F-B7C9-D6D85207ECE8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -75,4 +101,10 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2C4FAA2B-F991-4732-8D57-DFB1178440CA} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B66E4CB2-4F6B-4291-BC3A-44DF0474F04A} = {C65CC4CF-3D98-46FF-BF2D-D561AEBDD97C} + {5974619B-7169-4E63-A52F-3EF461B17509} = {C65CC4CF-3D98-46FF-BF2D-D561AEBDD97C} + {A775660C-0BC4-4B12-AF9D-41E21D751244} = {C65CC4CF-3D98-46FF-BF2D-D561AEBDD97C} + {48552EF6-DE98-404F-B7C9-D6D85207ECE8} = {C65CC4CF-3D98-46FF-BF2D-D561AEBDD97C} + EndGlobalSection EndGlobal diff --git a/src/AspireApp/AspireApp.ApiService/AspireApp.ApiService.csproj b/src/AspireApp/AspireApp.ApiService/AspireApp.ApiService.csproj new file mode 100644 index 0000000..1c0a354 --- /dev/null +++ b/src/AspireApp/AspireApp.ApiService/AspireApp.ApiService.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/AspireApp/AspireApp.ApiService/Program.cs b/src/AspireApp/AspireApp.ApiService/Program.cs new file mode 100644 index 0000000..2c9ec9c --- /dev/null +++ b/src/AspireApp/AspireApp.ApiService/Program.cs @@ -0,0 +1,54 @@ +using Marten; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire components. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddProblemDetails(); +// calling this directly gives me the wrong extension for whatever reason +AspirePostgreSqlNpgsqlExtensions.AddNpgsqlDataSource(builder,"apiservicedb"); +builder.Services.AddMarten(x => +{ + x.CreateDatabasesForTenants(c=> + c.ForTenant() + .CheckAgainstPgDatabase() + ); + + x.UseSystemTextJsonForSerialization(); + +}).ApplyAllDatabaseChangesOnStartup() + .UseNpgsqlDataSource(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +app.MapGet("/weatherforecast", async (IQuerySession session) => +{ + return await session.Query().ToListAsync(); +}); + +app.MapPost("/weatherforcast", async (CreateWeatherForecastRequest request, IDocumentSession session) => +{ + var forecast = new WeatherForecast(Guid.NewGuid(), request.Date, request.TemperatureC, request.Summary); + + session.Store(forecast); + + await session.SaveChangesAsync(); +}); + +app.MapDefaultEndpoints(); + +app.Run(); + + +public partial class Program {} +public record CreateWeatherForecastRequest(DateOnly Date, int TemperatureC, string? Summary); + +public record WeatherForecast(Guid Id, DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/src/AspireApp/AspireApp.ApiService/Properties/launchSettings.json b/src/AspireApp/AspireApp.ApiService/Properties/launchSettings.json new file mode 100644 index 0000000..4cf9603 --- /dev/null +++ b/src/AspireApp/AspireApp.ApiService/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5489", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7312;http://localhost:5489", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/AspireApp/AspireApp.ApiService/appsettings.Development.json b/src/AspireApp/AspireApp.ApiService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/AspireApp/AspireApp.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AspireApp/AspireApp.ApiService/appsettings.json b/src/AspireApp/AspireApp.ApiService/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/AspireApp/AspireApp.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj b/src/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj new file mode 100644 index 0000000..0700a5d --- /dev/null +++ b/src/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + true + 707F1E1C-F9AC-42D0-92BC-4CE87FD0EC4E + + + + + + + + + + + + diff --git a/src/AspireApp/AspireApp.AppHost/Program.cs b/src/AspireApp/AspireApp.AppHost/Program.cs new file mode 100644 index 0000000..9e18d91 --- /dev/null +++ b/src/AspireApp/AspireApp.AppHost/Program.cs @@ -0,0 +1,9 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgresdb").AddDatabase("apiservicedb"); + +builder + .AddProject("apiservice") + .WithReference(postgres); + +builder.Build().Run(); \ No newline at end of file diff --git a/src/AspireApp/AspireApp.AppHost/Properties/launchSettings.json b/src/AspireApp/AspireApp.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..900b325 --- /dev/null +++ b/src/AspireApp/AspireApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17083;http://localhost:15041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21024", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22268" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19152", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20090" + } + } + } +} diff --git a/src/AspireApp/AspireApp.AppHost/appsettings.Development.json b/src/AspireApp/AspireApp.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/AspireApp/AspireApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AspireApp/AspireApp.AppHost/appsettings.json b/src/AspireApp/AspireApp.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/AspireApp/AspireApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj b/src/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj new file mode 100644 index 0000000..0bdf402 --- /dev/null +++ b/src/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/src/AspireApp/AspireApp.ServiceDefaults/Extensions.cs b/src/AspireApp/AspireApp.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..0a2ee48 --- /dev/null +++ b/src/AspireApp/AspireApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} \ No newline at end of file diff --git a/src/AspireApp/AspireApp.Tests/AspireApp.Tests.csproj b/src/AspireApp/AspireApp.Tests/AspireApp.Tests.csproj new file mode 100644 index 0000000..de51a3f --- /dev/null +++ b/src/AspireApp/AspireApp.Tests/AspireApp.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AspireApp/AspireApp.Tests/IntegrationTest.cs b/src/AspireApp/AspireApp.Tests/IntegrationTest.cs new file mode 100644 index 0000000..6757ad0 --- /dev/null +++ b/src/AspireApp/AspireApp.Tests/IntegrationTest.cs @@ -0,0 +1,88 @@ +using Alba; +using Marten; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace AspireApp.Tests; + +[Collection(nameof(WebAppCollection))] +public class IntegrationTest +{ + private readonly IAlbaHost _alba; + + public IntegrationTest(WebAppFixture fixture) + { + _alba = fixture.Alba; + } + + [Fact] + public async Task CanCreateWeatherForecast() + { + var forecast = new CreateWeatherForecastRequest(DateOnly.FromDateTime(DateTime.Now), 23, "etc"); + await _alba.Scenario(x => + { + x.Post.Json(forecast) + .ToUrl("weatherforcast"); + x.StatusCodeShouldBeOk(); + }); + + var forecasts = await _alba.GetAsJson("weatherforcast"); + + Assert.Single(forecasts!); + } +} + + +[CollectionDefinition(nameof(WebAppCollection))] +public class WebAppCollection : ICollectionFixture +{ +} + + +public sealed class WebAppFixture : IAsyncLifetime +{ + public IAlbaHost Alba = null!; + private IHost _aspireHost = null!; + + public async Task DisposeAsync() + { + await Alba.Services.GetRequiredService().Advanced.Clean.DeleteAllDocumentsAsync(); + + await Alba.DisposeAsync(); + if (_aspireHost is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + _aspireHost.Dispose(); + } + } + + public async Task InitializeAsync() + { + var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); + appHost.Resources.Remove(appHost.Resources.Single(r => r.Name == "webfrontend")); + appHost.Resources.Remove(appHost.Resources.Single(r => r.Name == "apiservice")); + var postgres = (IResourceWithConnectionString)appHost.Resources.Single(x => x.Name == "postgres"); + _aspireHost = await appHost.BuildAsync(); + await _aspireHost.StartAsync(); + + var connectionString = await postgres.GetConnectionStringAsync(); + Alba = await AlbaHost.For(x => + { + x.ConfigureAppConfiguration((context, builder) => + { + builder.AddInMemoryCollection(new Dictionary + { + { $"ConnectionStrings:{postgres.Name}", connectionString }, + }); + }); + }); + } +} + + + +