diff --git a/docs/architecture.md b/docs/architecture.md index 330e7a5b..cd98e485 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ MediatR/DI, never over HTTP: └─────────────────────────┴─────────────────────────┴────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────────────┐ -│ Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing, │ +│ Database.Connection, Infrastructure.Jwt, Infrastructure.PasswordHashing,│ │ Infrastructure.Email, Infrastructure.Email.Templates │ └─────────────────────────────────────────────────────────────────────────┘ ↓ @@ -176,8 +176,8 @@ whichever slices need them **Components**: -- **Infrastructure.Sql**: generic ADO.NET connection/command plumbing - (`ISqlConnectionFactory`, the abstract `Repository` base class), not +- **Database.Connection**: generic ADO.NET connection/command plumbing + (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this - **Infrastructure.Jwt**: JWT token generation and validation - **Infrastructure.PasswordHashing**: Argon2id password hashing @@ -266,7 +266,7 @@ only: - `Features.Auth/Repository/IAuthRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.UserManagement/Repository/IUserAccountRepository.cs` -- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection +- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection factory every slice's repository builds on **Benefits**: @@ -295,7 +295,7 @@ method that registers its repository and slice-internal services **Lifetimes**: - Scoped: Repositories, slice-internal services (per request) -- Singleton: `ISqlConnectionFactory` +- Singleton: `DefaultSqlConnectionFactory` - Transient: Utilities, helpers #### Direct SQL via Dapper/ADO.NET diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index ce88bc14..f53cbdcd 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -17,7 +17,7 @@ - + diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index c2e028ea..b280a20a 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -17,6 +17,7 @@ WORKDIR /src COPY ["Biergarten.API.slnx", "./"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] +COPY ["Database/Database.Connection/Database.Connection.csproj", "Database/Database.Connection/"] COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"] COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] @@ -35,7 +36,6 @@ COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infras COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] -COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] RUN dotnet restore "Biergarten.API.slnx" diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 0a3cf8a9..6a5b1720 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -1,5 +1,6 @@ using API.Core; using API.Core.Authentication; +using Database.Connection.DependencyInjection; using Features.Auth.Controllers; using Features.Auth.DependencyInjection; using Features.Breweries.Controllers; @@ -11,7 +12,6 @@ using Features.UserManagement.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; using Infrastructure.Jwt; -using Infrastructure.Sql.DependencyInjection; using Microsoft.OpenApi.Models; using Shared.Application.Behaviors; @@ -87,7 +87,7 @@ builder.Logging.AddConsole(); if (!builder.Environment.IsProduction()) builder.Logging.AddDebug(); -builder.Services.AddInfrastructureSql(); +builder.Services.AddDatabaseConnection(); builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesUserManagement(); diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index a81d2ce7..d0251b13 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /src COPY ["Biergarten.API.slnx", "./"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] +COPY ["Database/Database.Connection/Database.Connection.csproj", "Database/Database.Connection/"] COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"] COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] @@ -25,7 +26,6 @@ COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infras COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] -COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] RUN dotnet restore "Biergarten.API.slnx" diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx index 7d869556..07900597 100644 --- a/web/backend/Biergarten.API.slnx +++ b/web/backend/Biergarten.API.slnx @@ -6,6 +6,7 @@ + @@ -16,7 +17,6 @@ - diff --git a/web/backend/Database/Database.Connection/ConfigurationHelpers.cs b/web/backend/Database/Database.Connection/ConfigurationHelpers.cs new file mode 100644 index 00000000..73340cf0 --- /dev/null +++ b/web/backend/Database/Database.Connection/ConfigurationHelpers.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Configuration; + +namespace Database.Connection; + +public static class ConfigurationHelpers +{ + public static string GetKeyOrThrow(IConfiguration configuration, string key) => + configuration[key] + ?? throw new InvalidOperationException($"The {key} environment variable is not set."); +} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj b/web/backend/Database/Database.Connection/Database.Connection.csproj similarity index 92% rename from web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj rename to web/backend/Database/Database.Connection/Database.Connection.csproj index 5bcf32c9..0cebb12a 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj +++ b/web/backend/Database/Database.Connection/Database.Connection.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - Infrastructure.Sql + Database.Connection diff --git a/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs new file mode 100644 index 00000000..c2b3996b --- /dev/null +++ b/web/backend/Database/Database.Connection/DefaultSqlConnectionFactory.cs @@ -0,0 +1,46 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Database.Connection; + +/// +/// Default implementation that creates SQL Server connections, +/// resolving the connection string from environment variables or application configuration. +/// +public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory +{ + /// + /// The resolved SQL Server connection string, preferring (in order): the DB_CONNECTION_STRING + /// configuration value, and a connection string built from individual DB_* configuration values. + /// + /// Thrown when no connection string can be resolved from any source. + public string ConnectionString + { + get + { + string? fullConnectionString = configuration["DB_CONNECTION_STRING"]; + if (!string.IsNullOrEmpty(fullConnectionString)) + return fullConnectionString; + + // Try to build from individual configuration values (preferred method for Docker) + if ( + SqlConnectionStringHelper.TryGetStandardDbConnectionString( + configuration, + out var connectionString + ) + ) + return connectionString; + + throw new InvalidOperationException( + "Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars." + ); + } + } + + /// + public SqlConnection CreateConnection() + { + return new SqlConnection(ConnectionString); + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs b/web/backend/Database/Database.Connection/DependencyInjection/DatabaseConnectionServiceCollectionExtensions.cs similarity index 55% rename from web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs rename to web/backend/Database/Database.Connection/DependencyInjection/DatabaseConnectionServiceCollectionExtensions.cs index 6573fe5b..6314a58b 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/DependencyInjection/InfrastructureSqlServiceCollectionExtensions.cs +++ b/web/backend/Database/Database.Connection/DependencyInjection/DatabaseConnectionServiceCollectionExtensions.cs @@ -1,13 +1,13 @@ using Microsoft.Extensions.DependencyInjection; -namespace Infrastructure.Sql.DependencyInjection; +namespace Database.Connection.DependencyInjection; -/// Extension methods for registering SQL infrastructure services with dependency injection. -public static class InfrastructureSqlServiceCollectionExtensions +/// Extension methods for registering database connection services with dependency injection. +public static class DatabaseConnectionServiceCollectionExtensions { /// Registers as a singleton . /// The same instance, for chaining. - public static IServiceCollection AddInfrastructureSql(this IServiceCollection services) + public static IServiceCollection AddDatabaseConnection(this IServiceCollection services) { services.AddSingleton(); return services; diff --git a/web/backend/Database/Database.Connection/ISqlConnectionFactory.cs b/web/backend/Database/Database.Connection/ISqlConnectionFactory.cs new file mode 100644 index 00000000..cd8ed905 --- /dev/null +++ b/web/backend/Database/Database.Connection/ISqlConnectionFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Data.SqlClient; + +namespace Database.Connection; + +public interface ISqlConnectionFactory +{ + public string ConnectionString { get; } + + public SqlConnection CreateConnection(); +} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs b/web/backend/Database/Database.Connection/Repository.cs similarity index 83% rename from web/backend/Infrastructure/Infrastructure.Sql/Repository.cs rename to web/backend/Database/Database.Connection/Repository.cs index 8ea5e110..b4a9f08f 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs +++ b/web/backend/Database/Database.Connection/Repository.cs @@ -1,13 +1,12 @@ using System.Data.Common; -namespace Infrastructure.Sql; +namespace Database.Connection; /// /// Base class for Dapper-based repositories, providing shared connection creation for derived /// repository implementations. /// -public abstract class Repository(ISqlConnectionFactory connectionFactory) - where T : class +public abstract class DapperRepository(ISqlConnectionFactory connectionFactory) { /// /// Creates and opens a new database connection using the configured . diff --git a/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs new file mode 100644 index 00000000..97e1fba3 --- /dev/null +++ b/web/backend/Database/Database.Connection/SqlConnectionStringHelper.cs @@ -0,0 +1,102 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Database.Connection; + +/// +/// Helper for building SQL Server connection strings from configuration +/// (backed by the DB_* environment variables). +/// +public static class SqlConnectionStringHelper +{ + private static SqlConnectionStringBuilder MakeConfiguredConnectionBuilder( + IConfiguration configuration + ) => + new() + { + DataSource = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_SERVER"), + UserID = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_USER"), + Password = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_PASSWORD"), + TrustServerCertificate = ResolveTrustServerCertificate(configuration), + Encrypt = true, + }; + + /// + /// Builds a SQL Server connection string from configuration. + /// + /// + /// Returns false if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset. + /// + /// The configuration to read the DB_* values from. + /// The resolved connection string, if successful. + public static bool TryGetStandardDbConnectionString( + IConfiguration configuration, + out string connectionString + ) + { + connectionString = string.Empty; + try + { + SqlConnectionStringBuilder builder = MakeConfiguredConnectionBuilder(configuration); + builder.InitialCatalog = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_NAME"); + connectionString = builder.ConnectionString; + return true; + } + catch + { + return false; + } + } + + public static bool TryGetMasterConnectionString( + IConfiguration configuration, + out string connectionString + ) + { + connectionString = string.Empty; + try + { + var builder = MakeConfiguredConnectionBuilder(configuration); + builder.InitialCatalog = "master"; + + connectionString = builder.ConnectionString; + + return true; + } + catch + { + return false; + } + } + + public static bool TryGetDatabaseNameFromConnectionString( + string connectionString, + out string databaseName + ) + { + databaseName = string.Empty; + + try + { + var builder = new SqlConnectionStringBuilder(connectionString); + databaseName = builder.InitialCatalog; + return true; + } + catch + { + return false; + } + } + + /// + /// Resolves the DB_TRUST_SERVER_CERTIFICATE configuration value, defaulting to + /// true when it is unset or not a valid boolean. + /// + private static bool ResolveTrustServerCertificate(IConfiguration configuration) + { + return !bool.TryParse( + configuration["DB_TRUST_SERVER_CERTIFICATE"], + out bool trustServerCertificate + ) || trustServerCertificate; + } +} diff --git a/web/backend/Database/Database.Migrations/Database.Migrations.csproj b/web/backend/Database/Database.Migrations/Database.Migrations.csproj index c3bfe487..42038c47 100644 --- a/web/backend/Database/Database.Migrations/Database.Migrations.csproj +++ b/web/backend/Database/Database.Migrations/Database.Migrations.csproj @@ -18,7 +18,7 @@ /> - + diff --git a/web/backend/Database/Database.Migrations/DbMigrator.cs b/web/backend/Database/Database.Migrations/DbMigrator.cs new file mode 100644 index 00000000..9b461ec8 --- /dev/null +++ b/web/backend/Database/Database.Migrations/DbMigrator.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using Dapper; +using Database.Connection; +using DbUp; +using DbUp.Engine; +using Microsoft.Data.SqlClient; + +namespace Database.Migrations; + +/// +/// Owns the configuration and state for a single database migration run, +/// and performs the drop/create/deploy steps against SQL Server. +/// +internal sealed class DbMigrator( + ISqlConnectionFactory standardConnectionStandardConnectionFactory, + ISqlConnectionFactory masterConnectionConnectionFactory, + bool clearDatabase) +{ + /// + /// Runs the full migration workflow: optional drop, create-if-not-exists, + /// then deploy DbUp scripts. Throws if the upgrade fails. + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + if (clearDatabase) + { + await DropDatabaseIfExistsAsync(cancellationToken); + } + + await CreateDatabaseIfNotExistsAsync(cancellationToken); + + DatabaseUpgradeResult result = DeployMigrations(); + + if (!result.Successful) + { + throw result.Error; + } + } + + private async Task DropDatabaseIfExistsAsync(CancellationToken cancellationToken = default) + { + string databaseName = ResolveDatabaseName(); + + await using SqlConnection connection = masterConnectionConnectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + DECLARE @statement nvarchar(max) = + N'ALTER DATABASE ' + QUOTENAME(@databaseName) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + + N' DROP DATABASE ' + QUOTENAME(@databaseName) + N';'; + + IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) + EXEC sys.sp_executesql @statement; + """, + new { databaseName }, + cancellationToken: cancellationToken + ) + ); + + Console.WriteLine($"Database '{databaseName}' dropped, or did not exist."); + } + + private async Task CreateDatabaseIfNotExistsAsync(CancellationToken cancellationToken = default) + { + string databaseName = ResolveDatabaseName(); + + await using SqlConnection connection = masterConnectionConnectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + DECLARE @statement nvarchar(max); + IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) + BEGIN + SET @statement = N'CREATE DATABASE ' + QUOTENAME(@databaseName) + N';'; + EXEC sys.sp_executesql @statement; + END + """, + new { databaseName }, + cancellationToken: cancellationToken + ) + ); + + Console.WriteLine($"Database '{databaseName}' is present."); + } + + private DatabaseUpgradeResult DeployMigrations() + { + UpgradeEngine upgrader = DeployChanges + .To.SqlDatabase(standardConnectionStandardConnectionFactory.ConnectionString) + .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) + .WithTransactionPerScript() + .LogToConsole() + .Build(); + + return upgrader.PerformUpgrade(); + } + + private string ResolveDatabaseName() + { + return !SqlConnectionStringHelper.TryGetDatabaseNameFromConnectionString( + standardConnectionStandardConnectionFactory.ConnectionString, + out var databaseName + ) + ? throw new InvalidOperationException( + "Unable to resolve a database name from the connection string." + ) + : databaseName; + } +} diff --git a/web/backend/Database/Database.Migrations/Dockerfile b/web/backend/Database/Database.Migrations/Dockerfile index 7878561e..4f2b1b8a 100644 --- a/web/backend/Database/Database.Migrations/Dockerfile +++ b/web/backend/Database/Database.Migrations/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src # Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so -# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve. +# ProjectReference paths resolve. COPY . . RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj" RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build diff --git a/web/backend/Database/Database.Migrations/MasterSqlConnectionFactory.cs b/web/backend/Database/Database.Migrations/MasterSqlConnectionFactory.cs new file mode 100644 index 00000000..151ddc3c --- /dev/null +++ b/web/backend/Database/Database.Migrations/MasterSqlConnectionFactory.cs @@ -0,0 +1,37 @@ +using Database.Connection; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Database.Migrations; + +/// +/// implementation that connects to the master database, +/// used for create and drop operations on the target application database. +/// +public class MasterSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory +{ + /// + public string ConnectionString + { + get + { + if ( + SqlConnectionStringHelper.TryGetMasterConnectionString( + configuration, + out var connectionString + ) + ) + return connectionString; + + throw new InvalidOperationException( + "Database connection string not configured. Set DB_SERVER, DB_USER, DB_PASSWORD env vars." + ); + } + } + + /// + public SqlConnection CreateConnection() + { + return new SqlConnection(ConnectionString); + } +} diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index e034d79f..7ad60ecb 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -1,28 +1,16 @@ -using System.Data; -using System.Reflection; -using DbUp; -using DbUp.Engine; -using Infrastructure.Sql; +using Database.Connection; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; namespace Database.Migrations; /// -/// Entry point for the database migration runner. Reads connection details from configuration -/// (backed by environment variables), optionally drops and recreates the target database, and -/// applies all SQL migration scripts embedded in this assembly using DbUp. +/// Application entry point. Responsible only for wiring up configuration +/// and delegating the migration run to . /// -public static class Program +internal static class Program { - /// - /// Migration runner entry point. Drops the target database when CLEAR_DATABASE is - /// set to true, ensures the database exists, then deploys all pending migrations. - /// - /// - /// 0 if migrations completed successfully; 1 if they failed or an error occurred. - /// - public static async Task Main() + private static async Task Main(string[] args) { Console.WriteLine("Starting database migrations..."); @@ -31,150 +19,6 @@ public static class Program IConfiguration configuration = new ConfigurationBuilder() .AddEnvironmentVariables() .Build(); - DatabaseSettings settings = DatabaseSettings.FromConfiguration(configuration); - - if (settings.ClearDatabase) - { - Console.WriteLine( - $"CLEAR_DATABASE is enabled. Dropping database '{settings.DatabaseName}'..." - ); - await DropDatabaseIfExistsAsync(settings); - } - - await CreateDatabaseIfNotExistsAsync(settings); - - return DeployMigrations(settings.ConnectionString) ? 0 : 1; - } - catch (Exception ex) when (ex is InvalidOperationException or SqlException) - { - Console.Error.WriteLine("An error occurred during database migrations:"); - Console.Error.WriteLine(ex.Message); - return 1; - } - } - - /// - /// Drops the target database if it exists, first forcing it into single-user mode with - /// rollback immediate so that any existing connections are terminated. - /// - /// The resolved database settings. - /// Thrown if the connection or the drop operation fails. - private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings) - { - const string sql = """ - DECLARE @statement nvarchar(max); - - IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) - BEGIN - SET @statement = - N'ALTER DATABASE ' + QUOTENAME(@databaseName) - + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;'; - EXEC sys.sp_executesql @statement; - - SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';'; - EXEC sys.sp_executesql @statement; - END - """; - - await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName); - Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist."); - } - - /// - /// Creates the target database on the master connection if it does not already exist. - /// - /// The resolved database settings. - /// Thrown if the connection or the create operation fails. - private static async Task CreateDatabaseIfNotExistsAsync(DatabaseSettings settings) - { - const string sql = """ - DECLARE @statement nvarchar(max); - - IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) - BEGIN - SET @statement = N'CREATE DATABASE ' + QUOTENAME(@databaseName) + N';'; - EXEC sys.sp_executesql @statement; - END - """; - - await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName); - Console.WriteLine($"Database '{settings.DatabaseName}' is present."); - } - - /// The value bound to the @databaseName parameter. - private static async Task ExecuteNonQueryAsync( - string connectionString, - string sql, - string databaseName - ) - { - await using SqlConnection connection = new(connectionString); - await connection.OpenAsync(); - - await using SqlCommand command = new(sql, connection); - command.Parameters.Add("@databaseName", SqlDbType.NVarChar, 128).Value = databaseName; - - await command.ExecuteNonQueryAsync(); - } - - /// - /// Applies all pending SQL migration scripts embedded in this assembly to the target - /// database using DbUp, logging progress to the console. - /// - /// The connection string for the target application database. - /// true if the upgrade completed successfully; otherwise false. - private static bool DeployMigrations(string connectionString) - { - UpgradeEngine upgrader = DeployChanges - .To.SqlDatabase(connectionString) - .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) - .WithTransactionPerScript() - .LogToConsole() - .Build(); - - DatabaseUpgradeResult result = upgrader.PerformUpgrade(); - - if (result.Successful) - { - Console.WriteLine("Database migrations completed successfully."); - return true; - } - - Console.Error.WriteLine($"Database migrations failed: {result.Error}"); - return false; - } - - /// - /// Connection details and runtime options resolved from environment variables. - /// - /// The connection string for the target application database. - /// - /// The connection string for the master database, used for create and drop operations. - /// - /// The name of the target application database. - /// Whether the target database should be dropped before migrating. - private sealed record DatabaseSettings( - string ConnectionString, - string MasterConnectionString, - string DatabaseName, - bool ClearDatabase - ) - { - /// - /// Builds the settings from the DB_SERVER, DB_NAME, DB_USER, - /// DB_PASSWORD, DB_TRUST_SERVER_CERTIFICATE, and CLEAR_DATABASE - /// configuration values. - /// - /// - /// Thrown when a required configuration value is not set. - /// - public static DatabaseSettings FromConfiguration(IConfiguration configuration) - { - string databaseName = - configuration["DB_NAME"] - ?? throw new InvalidOperationException( - "The DB_NAME environment variable is not set." - ); bool clearDatabase = string.Equals( configuration["CLEAR_DATABASE"], @@ -182,12 +26,17 @@ public static class Program StringComparison.OrdinalIgnoreCase ); - return new DatabaseSettings( - SqlConnectionStringHelper.BuildConnectionString(configuration, databaseName), - SqlConnectionStringHelper.BuildMasterConnectionString(configuration), - databaseName, + DbMigrator migrator = new( + new DefaultSqlConnectionFactory(configuration), + new MasterSqlConnectionFactory(configuration), clearDatabase ); + await migrator.RunAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or SqlException) + { + Console.Error.WriteLine("An error occurred during database migrations:"); + Console.Error.WriteLine(ex.Message); } } } diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 10fca1b3..3ecbf16f 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -16,7 +16,7 @@ - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs index f43c8bc8..d8a05ef8 100644 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs @@ -1,16 +1,23 @@ #region -using Infrastructure.Sql; +using Database.Connection; using Microsoft.Extensions.Configuration; #endregion namespace Database.Seed.DatabaseHelpers; -public class ConnectionStrings +public static class ConnectionStrings { public static string GetSqlServerConnectionString(IConfiguration configuration) => - SqlConnectionStringHelper.BuildConnectionString(configuration); + SqlConnectionStringHelper.TryGetStandardDbConnectionString( + configuration, + out string connectionString + ) + ? connectionString + : throw new InvalidOperationException( + "Database connection string not configured. Set DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars." + ); public static string SqliteConnectionString => "Data Source=SeedData/biergarten_seed_2026-08-24T01-52-09.841693Z.sqlite"; diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 9473da33..df54d1f6 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -1,3 +1,4 @@ +using Database.Connection.DependencyInjection; using Database.Seed.DatabaseHelpers; using Database.Seed.Geocoding; using Database.Seed.PipelineData; @@ -11,7 +12,6 @@ using Features.Locations.DependencyInjection; using Features.Locations.Dtos; using Features.Locations.Repository; using idunno.Password; -using Infrastructure.Sql.DependencyInjection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -30,7 +30,7 @@ static async Task RunAsync() ServiceCollection services = []; services.AddSingleton(configuration); - services.AddInfrastructureSql(); + services.AddDatabaseConnection(); services.AddFeaturesBreweries(); services.AddFeaturesLocations(); services.AddFeaturesAuth(); diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs index cb8e621b..0c06683b 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs @@ -38,42 +38,6 @@ public class TokenServiceValidationTests ); } - [Fact] - public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken() - { - // Arrange - Guid userId = Guid.NewGuid(); - const string username = "testuser"; - const string token = "valid-access-token"; - - List claims = new() - { - new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), - new Claim(JwtRegisteredClaimNames.UniqueName, username), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - }; - - ClaimsIdentity claimsIdentity = new(claims); - ClaimsPrincipal principal = new(claimsIdentity); - - _tokenInfraMock - .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) - .ReturnsAsync(principal); - - // Act - ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token); - - // Assert - result.Should().NotBeNull(); - result.UserId.Should().Be(userId); - result.Username.Should().Be(username); - result.Principal.Should().NotBeNull(); - result - .Principal.FindFirst(JwtRegisteredClaimNames.Sub) - ?.Value.Should() - .Be(userId.ToString()); - } - [Fact] public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken() { @@ -137,24 +101,7 @@ public class TokenServiceValidationTests } [Fact] - public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() - { - // Arrange - const string token = "invalid-token"; - - _tokenInfraMock - .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) - .ThrowsAsync(new UnauthorizedException("Invalid token")); - - // Act & Assert - await FluentActions - .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token)) - .Should() - .ThrowAsync(); - } - - [Fact] - public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException() + public async Task ValidateRefreshTokenAsync_WithExpiredToken_ThrowsUnauthorizedException() { // Arrange const string token = "expired-token"; @@ -165,13 +112,13 @@ public class TokenServiceValidationTests // Act & Assert await FluentActions - .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token)) + .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token)) .Should() .ThrowAsync(); } [Fact] - public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException() + public async Task ValidateRefreshTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException() { // Arrange const string username = "testuser"; @@ -193,14 +140,14 @@ public class TokenServiceValidationTests // Act & Assert await FluentActions - .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token)) + .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token)) .Should() .ThrowAsync() .WithMessage("*missing required claims*"); } [Fact] - public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException() + public async Task ValidateRefreshTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException() { // Arrange Guid userId = Guid.NewGuid(); @@ -222,14 +169,14 @@ public class TokenServiceValidationTests // Act & Assert await FluentActions - .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token)) + .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token)) .Should() .ThrowAsync() .WithMessage("*missing required claims*"); } [Fact] - public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException() + public async Task ValidateRefreshTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException() { // Arrange const string username = "testuser"; @@ -252,7 +199,7 @@ public class TokenServiceValidationTests // Act & Assert await FluentActions - .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token)) + .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token)) .Should() .ThrowAsync() .WithMessage("*malformed user ID*"); diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj index 96377056..1ea6af4c 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -16,7 +16,7 @@ - + diff --git a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs index e43b0805..fb2780a9 100644 --- a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs +++ b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs @@ -1,6 +1,6 @@ using System.Data.Common; using Dapper; -using Infrastructure.Sql; +using Database.Connection; using Microsoft.AspNetCore.Identity; using Microsoft.Data.SqlClient; @@ -14,7 +14,7 @@ namespace Features.Auth.Identity; /// existing unique indexes are already case-insensitive under SQL Server's default collation). /// public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), + : DapperRepository(connectionFactory), IUserStore, IUserPasswordStore, IUserEmailStore diff --git a/web/backend/Features/Features.Auth/Services/ITokenService.cs b/web/backend/Features/Features.Auth/Services/ITokenService.cs index 3a61e917..ff62f0cc 100644 --- a/web/backend/Features/Features.Auth/Services/ITokenService.cs +++ b/web/backend/Features/Features.Auth/Services/ITokenService.cs @@ -66,12 +66,6 @@ public interface ITokenService /// string GenerateConfirmationToken(Guid userId, string username); - /// Validates an access token's signature and expiration and extracts the caller's identity. - /// - /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. - /// - Task ValidateAccessTokenAsync(string token); - /// Validates a refresh token's signature and expiration and extracts the caller's identity. /// /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs index ad40a935..00f3e4ce 100644 --- a/web/backend/Features/Features.Auth/Services/TokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -82,12 +82,6 @@ public class TokenService : ITokenService ); } - /// - public async Task ValidateAccessTokenAsync(string token) - { - return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); - } - /// public async Task ValidateRefreshTokenAsync(string token) { diff --git a/web/backend/Features/Features.Beers/Features.Beers.csproj b/web/backend/Features/Features.Beers/Features.Beers.csproj index d7c3fe27..ee1d2967 100644 --- a/web/backend/Features/Features.Beers/Features.Beers.csproj +++ b/web/backend/Features/Features.Beers/Features.Beers.csproj @@ -13,7 +13,7 @@ - + diff --git a/web/backend/Features/Features.Breweries/Features.Breweries.csproj b/web/backend/Features/Features.Breweries/Features.Breweries.csproj index d7c3fe27..ee1d2967 100644 --- a/web/backend/Features/Features.Breweries/Features.Breweries.csproj +++ b/web/backend/Features/Features.Breweries/Features.Breweries.csproj @@ -13,7 +13,7 @@ - + diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 792d3052..42a45f52 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -1,8 +1,8 @@ using System.Data.Common; using Dapper; +using Database.Connection; using Domain.Entities; using Domain.Exceptions; -using Infrastructure.Sql; namespace Features.Breweries.Repository; @@ -17,7 +17,7 @@ namespace Features.Breweries.Repository; /// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly. /// public class BreweryRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), + : Database.Connection.DapperRepository(connectionFactory), IBreweryRepository { /// diff --git a/web/backend/Features/Features.Locations/Features.Locations.csproj b/web/backend/Features/Features.Locations/Features.Locations.csproj index 48c9c461..59a0d4c2 100644 --- a/web/backend/Features/Features.Locations/Features.Locations.csproj +++ b/web/backend/Features/Features.Locations/Features.Locations.csproj @@ -13,7 +13,7 @@ - + diff --git a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs index d904006a..4ee8b809 100644 --- a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs +++ b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs @@ -1,9 +1,8 @@ using System.Data.Common; using Dapper; -using Domain.Entities; +using Database.Connection; using Domain.Exceptions; using Features.Locations.Dtos; -using Infrastructure.Sql; namespace Features.Locations.Repository; @@ -11,7 +10,7 @@ namespace Features.Locations.Repository; /// Dapper-based implementation of . /// public class LocationRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), + : Database.Connection.DapperRepository(connectionFactory), ILocationRepository { /// diff --git a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj b/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj index 0aa52271..77bfb16b 100644 --- a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj +++ b/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj @@ -13,7 +13,7 @@ - + diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index 11a0a353..a994dd4f 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -1,8 +1,8 @@ using System.Data.Common; using Dapper; +using Database.Connection; using Domain.Entities; using Domain.Exceptions; -using Infrastructure.Sql; namespace Features.UserManagement.Repository; @@ -10,7 +10,7 @@ namespace Features.UserManagement.Repository; /// Dapper-based implementation of . /// public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), + : Database.Connection.DapperRepository(connectionFactory), IUserAccountRepository { /// diff --git a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs deleted file mode 100644 index c38500ba..00000000 --- a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Data.Common; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Configuration; - -namespace Infrastructure.Sql; - -/// -/// Default implementation that creates SQL Server connections, -/// resolving the connection string from environment variables or application configuration. -/// -public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory -{ - private readonly string _connectionString = GetConnectionString(configuration); - - /// - public DbConnection CreateConnection() - { - return new SqlConnection(_connectionString); - } - - /// - /// Resolves the SQL Server connection string, preferring (in order): the DB_CONNECTION_STRING - /// configuration value, a connection string built from individual DB_* configuration values, - /// and finally the "Default" connection string from configuration. - /// - /// Thrown when no connection string can be resolved from any source. - private static string GetConnectionString(IConfiguration configuration) - { - string? fullConnectionString = configuration["DB_CONNECTION_STRING"]; - if (!string.IsNullOrEmpty(fullConnectionString)) - return fullConnectionString; - - // Try to build from individual configuration values (preferred method for Docker) - try - { - return SqlConnectionStringHelper.BuildConnectionString(configuration); - } - catch (InvalidOperationException) - { - // Fall back to configuration-based connection string if env vars are not set - string? connString = configuration.GetConnectionString("Default"); - if (!string.IsNullOrEmpty(connString)) - return connString; - - throw new InvalidOperationException( - "Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default." - ); - } - } -} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs deleted file mode 100644 index 9561d9f5..00000000 --- a/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; - -namespace Infrastructure.Sql; - -/// -/// Factory for creating database connections used by repositories. -/// -public interface ISqlConnectionFactory -{ - /// - /// Creates a new, unopened database connection. - /// - DbConnection CreateConnection(); -} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs deleted file mode 100644 index c639c3af..00000000 --- a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Configuration; - -namespace Infrastructure.Sql; - -/// -/// Helper for building SQL Server connection strings from configuration -/// (backed by the DB_* environment variables). -/// -public static class SqlConnectionStringHelper -{ - /// - /// Builds a SQL Server connection string from configuration. - /// - /// Throws if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset. - /// The configuration to read the DB_* values from. - /// If null, falls back to the DB_NAME configuration value. - public static string BuildConnectionString( - IConfiguration configuration, - string? databaseName = null - ) - { - string server = Required(configuration, "DB_SERVER"); - string dbName = databaseName ?? Required(configuration, "DB_NAME"); - string user = Required(configuration, "DB_USER"); - string password = Required(configuration, "DB_PASSWORD"); - - SqlConnectionStringBuilder builder = new() - { - DataSource = server, - InitialCatalog = dbName, - UserID = user, - Password = password, - TrustServerCertificate = ResolveTrustServerCertificate(configuration), - Encrypt = true, - }; - - return builder.ConnectionString; - } - - public static string BuildMasterConnectionString(IConfiguration configuration) - { - return BuildConnectionString(configuration, "master"); - } - - /// - /// Resolves the DB_TRUST_SERVER_CERTIFICATE configuration value, defaulting to - /// true when it is unset or not a valid boolean. - /// - private static bool ResolveTrustServerCertificate(IConfiguration configuration) - { - return !bool.TryParse( - configuration["DB_TRUST_SERVER_CERTIFICATE"], - out bool trustServerCertificate - ) || trustServerCertificate; - } - - private static string Required(IConfiguration configuration, string key) => - configuration[key] - ?? throw new InvalidOperationException($"The {key} environment variable is not set."); -} diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs deleted file mode 100644 index 5a55e566..00000000 --- a/web/backend/Infrastructure/Infrastructure.Sql/SqlErrorCodes.cs +++ /dev/null @@ -1 +0,0 @@ -namespace Infrastructure.Sql;