From 78ad32e93a7c808a152b42ad1aba7c7da2964d05 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 3 Sep 2026 05:19:20 -0400 Subject: [PATCH] refactor(api-core): move service registration (#275) --- docs/web/architecture.md | 3 +- docs/web/token-validation.md | 3 +- web/backend/API/API.Core/API.Core.csproj | 4 + .../JwtAuthenticationHandler.cs | 8 +- .../Controllers/NotFoundController.cs | 25 --- .../Controllers/ProtectedController.cs | 34 --- web/backend/API/API.Core/GlobalException.cs | 20 +- web/backend/API/API.Core/Program.cs | 196 +++++++----------- .../API.Core/ServiceCollectionExtensions.cs | 121 +++++++++++ .../API/API.Specs/Features/NotFound.feature | 2 +- .../API/API.Specs/Steps/ApiStepsBase.cs | 2 +- web/backend/API/API.Specs/TestApiFactory.cs | 3 +- 12 files changed, 220 insertions(+), 201 deletions(-) delete mode 100644 web/backend/API/API.Core/Controllers/NotFoundController.cs delete mode 100644 web/backend/API/API.Core/Controllers/ProtectedController.cs create mode 100644 web/backend/API/API.Core/ServiceCollectionExtensions.cs diff --git a/docs/web/architecture.md b/docs/web/architecture.md index de5614e4..07d465df 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -123,7 +123,8 @@ own | Component | Description | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. | +| `Program.cs` | Thin composition root: builds the host and calls the `ServiceCollectionExtensions` methods below. | +| `ServiceCollectionExtensions.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. | | `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). | | `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. | | Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. | diff --git a/docs/web/token-validation.md b/docs/web/token-validation.md index bfb0ad65..aff82837 100644 --- a/docs/web/token-validation.md +++ b/docs/web/token-validation.md @@ -79,7 +79,8 @@ directly, bypassing the `Features.Users` slice entirely. `TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` extension method, except for the lower-level `ITokenInfrastructure` (JWT signing/verification) it depends on, which is registered by the host -(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth +(`API.Core/ServiceCollectionExtensions.cs`, via `AddCoreInfrastructure()`, +called from `Program.cs`) since `JwtAuthenticationHandler` (host-level auth middleware) also depends on it directly. ### Integration points diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 09478802..70efde10 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -7,6 +7,10 @@ Linux + + + + diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index 35915fe1..d838cd24 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -25,17 +25,13 @@ public class JwtAuthenticationHandler( /// Validates the incoming request's bearer JWT access token and produces an authentication result. /// /// - /// The signing secret is resolved first from the ACCESS_TOKEN_SECRET environment variable, falling - /// back to the Jwt:SecretKey configuration value, to stay consistent with the secret source used - /// when tokens are issued. Fails if the secret is not configured, the Authorization header is + /// The signing secret is resolved first from the ACCESS_TOKEN_SECRET environment variable. + /// Fails if the secret is not configured, the Authorization header is /// missing or not Bearer-scheme, or the token fails validation. /// protected override async Task HandleAuthenticateAsync() { - // Use the same access-token secret source as TokenService to avoid mismatched validation. string? secret = configuration[ConfigurationKeys.AccessTokenSecret]; - if (string.IsNullOrWhiteSpace(secret)) - secret = configuration[ConfigurationKeys.JwtSecretKeyFallback]; if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured"); diff --git a/web/backend/API/API.Core/Controllers/NotFoundController.cs b/web/backend/API/API.Core/Controllers/NotFoundController.cs deleted file mode 100644 index f232fc33..00000000 --- a/web/backend/API/API.Core/Controllers/NotFoundController.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace API.Core.Controllers; - -/// -/// Handles requests that do not match any other route, used as the application's fallback controller. -/// -/// -/// Wired up as the fallback target via app.MapFallbackToController("Handle404", "NotFound") in -/// Program.cs. -/// -[ApiController] -[ApiExplorerSettings(IgnoreApi = true)] -[Route("error")] -public class NotFoundController : ControllerBase -{ - /// - /// Returns a 404 response for any request that did not match a route. - /// - [HttpGet("404")] - public IActionResult Handle404() - { - return NotFound(new { message = "Route not found." }); - } -} diff --git a/web/backend/API/API.Core/Controllers/ProtectedController.cs b/web/backend/API/API.Core/Controllers/ProtectedController.cs deleted file mode 100644 index 36964b99..00000000 --- a/web/backend/API/API.Core/Controllers/ProtectedController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Shared.Contracts; -using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; - -namespace API.Core.Controllers; - -/// -/// Sample controller demonstrating an endpoint that requires JWT authentication. -/// -[ApiController] -[Route("api/[controller]")] -[Authorize(AuthenticationSchemes = "JWT")] -public class ProtectedController : ControllerBase -{ - /// - /// Returns the authenticated caller's user ID and username extracted from the JWT claims. - /// - /// userId and username may be null if not present in the token. - [HttpGet] - public ActionResult> Get() - { - string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; - string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; - - return Ok( - new ResponseBody - { - Message = "Protected endpoint accessed successfully", - Payload = new { userId, username }, - } - ); - } -} diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index 0d21c692..5868e981 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -13,16 +13,16 @@ namespace API.Core; /// public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter { - /// - /// Logs the exception and sets to an appropriate error response. - /// - /// - /// Maps to 400, to 409, - /// to 404, to 401, - /// to 403, to 503, - /// to 400, and anything else to 500. - /// - public void OnException(ExceptionContext context) + /// + /// Logs the exception and sets to an appropriate error response. + /// + /// + /// Maps to 400, to 409, + /// to 404, to 401, + /// to 403, to 503, + /// to 400, and anything else to 500. + /// + public void OnException(ExceptionContext context) { logger.LogError(context.Exception, "Unhandled exception occurred"); diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 0ad30d04..c9dc4f7c 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -1,132 +1,86 @@ -using API.Core; -using API.Core.Authentication; -using Database.Connection.DependencyInjection; -using Features.Auth.Controllers; -using Features.Users.DependencyInjection; -using Features.Breweries.Controllers; -using Features.Breweries.DependencyInjection; -using Features.Emails.DependencyInjection; -using Features.Emails.Services; -using Features.ImageUploads.Commands.UploadPhoto; -using Features.ImageUploads.DependencyInjection; -using FluentValidation; -using FluentValidation.AspNetCore; -using Infrastructure.FileUpload; -using Infrastructure.Jwt; -using MediatR; -using Microsoft.OpenApi.Models; -using Shared.Application.Behaviors; using System.Security.Claims; +using Shared.Contracts; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +namespace API.Core; -// Add current controllers to the exception filter -builder - .Services.AddControllers(options => { options.Filters.Add(); }) - .AddApplicationPart(typeof(BreweryController).Assembly) - .AddApplicationPart(typeof(AuthController).Assembly); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => +internal class Program { - options.AddSecurityDefinition( - "Bearer", - new OpenApiSecurityScheme - { - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "Bearer", - BearerFormat = "JWT", - In = ParameterLocation.Header, - Description = "Enter a JWT access token.", - } - ); - options.AddSecurityRequirement( - new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme + public static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + builder.Logging.ClearProviders().AddConsole(); + + if (!builder.Environment.IsProduction()) + builder.Logging.AddDebug(); + + builder + .Services.AddApiControllers() + .AddValidationAndMediatR() + .AddApplicationServices() + .AddFeatureModules() + .AddCoreInfrastructure() + .AddJwtAuthentication(); + + WebApplication app = builder.Build(); + + app.UseSwagger() + .UseSwaggerUI() + .UseHttpsRedirection() + .UseAuthentication() + .UseAuthorization(); + + app.MapOpenApi(); + app.MapHealthChecks("/health"); + app.MapControllers(); + + if (app.Environment.IsEnvironment("Testing")) + app.MapGet( + "/api/protected", + (ClaimsPrincipal user) => { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer", - }, - }, - [] - }, - } - ); -}); -builder.Services.AddOpenApi(); + string? userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + string? username = user.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; -builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); -builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); -builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly); -builder.Services.AddFluentValidationAutoValidation(); + return Results.Ok( + new ResponseBody + { + Message = "Protected endpoint accessed successfully", + Payload = new { userId, username }, + } + ); + } + ).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser()); -// Add MediatR. -// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers. -builder.Services.AddMediatR(cfg => -{ - cfg.RegisterServicesFromAssemblyContaining(); - cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); - cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); - cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); - cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); - cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); -}); + // Easter egg, as per tradition with the previous versions of the Biergarten App. + app.Map( + "/teapot", + () => + Results.Json( + new ResponseBody + { + Message = + "I'm a little teapot, short and stout. Here is my handle, here is my spout!", + }, + statusCode: 418 + ) + ) + .ExcludeFromDescription(); -builder.Services.AddHealthChecks(); + app.MapFallback(() => + Results.NotFound( + new ResponseBody { Message = "Are you lost? That route does not exist." } + ) + ) + .ExcludeFromDescription(); -// Configure logging for container output -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); -if (!builder.Environment.IsProduction()) - builder.Logging.AddDebug(); + app.Services.GetRequiredService() + .ApplicationStopping.Register(() => + { + app.Logger.LogInformation("Application is shutting down gracefully..."); + }); -builder.Services.AddDatabaseConnection(); - -builder.Services.AddFeaturesBreweries(); -builder.Services.AddFeaturesUsers(); -builder.Services.AddFeaturesEmails(); -builder.Services.AddFeaturesPhotoUpload(); - -// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. -builder.Services.AddScoped(); - -builder.Services.AddSingleton(); - -builder.Services.AddScoped(); - -builder - .Services.AddAuthentication("JWT") - .AddScheme("JWT", options => { }); - -builder.Services.AddAuthorization(); - -WebApplication app = builder.Build(); - -app.UseSwagger(); -app.UseSwaggerUI(); -app.MapOpenApi(); - -app.UseHttpsRedirection(); - -app.UseAuthentication(); -app.UseAuthorization(); -app.MapHealthChecks("/health"); - -app.MapControllers(); - -app.MapFallbackToController("Handle404", "NotFound"); - -IHostApplicationLifetime lifetime = app.Services.GetRequiredService(); -lifetime.ApplicationStopping.Register(() => -{ - app.Logger.LogInformation("Application is shutting down gracefully..."); -}); - -app.Run(); \ No newline at end of file + app.Run(); + } +} diff --git a/web/backend/API/API.Core/ServiceCollectionExtensions.cs b/web/backend/API/API.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..07e222c8 --- /dev/null +++ b/web/backend/API/API.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,121 @@ +using API.Core.Authentication; +using Database.Connection.DependencyInjection; +using Features.Auth.Controllers; +using Features.Breweries.Controllers; +using Features.Breweries.DependencyInjection; +using Features.Emails.DependencyInjection; +using Features.Emails.Services; +using Features.ImageUploads.Commands.UploadPhoto; +using Features.ImageUploads.DependencyInjection; +using Features.Users.DependencyInjection; +using FluentValidation; +using FluentValidation.AspNetCore; +using Infrastructure.FileUpload; +using Infrastructure.Jwt; +using Microsoft.OpenApi.Models; +using Shared.Application.Behaviors; + +namespace API.Core; + +internal static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddApiControllers() => + services + .AddControllers(options => options.Filters.Add()) + .AddApplicationPart(typeof(BreweryController).Assembly) + .AddApplicationPart(typeof(AuthController).Assembly) + .Services; + + public IServiceCollection AddValidationAndMediatR() + { + services + .AddOpenApi() + .AddValidatorsFromAssemblyContaining() + .AddValidatorsFromAssembly(typeof(BreweryController).Assembly) + .AddValidatorsFromAssembly(typeof(AuthController).Assembly) + .AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly) + .AddFluentValidationAutoValidation() + .AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); + cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); + cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); + cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + + return services; + } + + public IServiceCollection AddApplicationServices() + { + services + .AddEndpointsApiExplorer() + .AddSwaggerGen(options => + { + options.AddSecurityDefinition( + "Bearer", + new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter a JWT access token.", + } + ); + options.AddSecurityRequirement( + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer", + }, + }, + [] + }, + } + ); + }); + + services.AddHealthChecks(); + return services; + } + + public IServiceCollection AddFeatureModules() => + services + .AddDatabaseConnection() + .AddFeaturesBreweries() + .AddFeaturesUsers() + .AddFeaturesEmails() + .AddFeaturesPhotoUpload(); + + public IServiceCollection AddCoreInfrastructure() => + services + // ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. + .AddScoped() + .AddSingleton() + .AddScoped(); + + public IServiceCollection AddJwtAuthentication() + { + services + .AddAuthentication("JWT") + .AddScheme( + "JWT", + options => { } + ); + + services.AddAuthorization(); + return services; + } + } +} diff --git a/web/backend/API/API.Specs/Features/NotFound.feature b/web/backend/API/API.Specs/Features/NotFound.feature index 1e5e1cb9..e967edff 100644 --- a/web/backend/API/API.Specs/Features/NotFound.feature +++ b/web/backend/API/API.Specs/Features/NotFound.feature @@ -7,4 +7,4 @@ Scenario: GET request to an invalid route returns 404 Given the API is running When I send an HTTP request "GET" to "/invalid-route" Then the response has HTTP status 404 - And the response JSON should have "message" equal "Route not found." \ No newline at end of file + And the response JSON should have "message" equal "Are you lost? That route does not exist." \ No newline at end of file diff --git a/web/backend/API/API.Specs/Steps/ApiStepsBase.cs b/web/backend/API/API.Specs/Steps/ApiStepsBase.cs index f4bd6766..58bb3101 100644 --- a/web/backend/API/API.Specs/Steps/ApiStepsBase.cs +++ b/web/backend/API/API.Specs/Steps/ApiStepsBase.cs @@ -15,7 +15,7 @@ public abstract class ApiStepsBase(ScenarioContext scenario) protected const string ResponseKey = "response"; protected const string ResponseBodyKey = "responseBody"; - protected TestApiFactory GetFactory() + internal TestApiFactory GetFactory() { TestApiFactory? factory = Scenario.TryGetValue( FactoryKey, diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs index d11c87d3..403dd49c 100644 --- a/web/backend/API/API.Specs/TestApiFactory.cs +++ b/web/backend/API/API.Specs/TestApiFactory.cs @@ -1,3 +1,4 @@ +using API.Core; using API.Specs.Mocks; using Features.Emails.Services; using Infrastructure.Email; @@ -7,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace API.Specs; -public class TestApiFactory : WebApplicationFactory +internal class TestApiFactory : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) {