refactor(api-core): move service registration (#275)

This commit is contained in:
Aaron Po
2026-09-03 05:19:20 -04:00
committed by GitHub
parent d6e176bcd9
commit 78ad32e93a
12 changed files with 220 additions and 201 deletions

View File

@@ -123,7 +123,8 @@ own
| Component | Description | | 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). | | `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. | | `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. | | Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |

View File

@@ -79,7 +79,8 @@ directly, bypassing the `Features.Users` slice entirely.
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` `TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
extension method, except for the lower-level `ITokenInfrastructure` (JWT extension method, except for the lower-level `ITokenInfrastructure` (JWT
signing/verification) it depends on, which is registered by the host 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. middleware) also depends on it directly.
### Integration points ### Integration points

View File

@@ -7,6 +7,10 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="API.Specs" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />

View File

@@ -25,17 +25,13 @@ public class JwtAuthenticationHandler(
/// Validates the incoming request's bearer JWT access token and produces an authentication result. /// Validates the incoming request's bearer JWT access token and produces an authentication result.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling /// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable.
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used /// Fails if the secret is not configured, the <c>Authorization</c> header is
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
/// missing or not <c>Bearer</c>-scheme, or the token fails validation. /// missing or not <c>Bearer</c>-scheme, or the token fails validation.
/// </remarks> /// </remarks>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Use the same access-token secret source as TokenService to avoid mismatched validation.
string? secret = configuration[ConfigurationKeys.AccessTokenSecret]; string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
if (string.IsNullOrWhiteSpace(secret))
secret = configuration[ConfigurationKeys.JwtSecretKeyFallback];
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
return AuthenticateResult.Fail("JWT secret is not configured"); return AuthenticateResult.Fail("JWT secret is not configured");

View File

@@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")]
public class NotFoundController : ControllerBase
{
/// <summary>
/// Returns a 404 response for any request that did not match a route.
/// </summary>
[HttpGet("404")]
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });
}
}

View File

@@ -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;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase
{
/// <summary>
/// Returns the authenticated caller's user ID and username extracted from the JWT claims.
/// </summary>
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
return Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
}
);
}
}

View File

@@ -13,16 +13,16 @@ namespace API.Core;
/// </summary> /// </summary>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{ {
/// <summary> /// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response. /// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409, /// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401, /// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503, /// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500. /// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
/// </remarks> /// </remarks>
public void OnException(ExceptionContext context) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");

View File

@@ -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 System.Security.Claims;
using Shared.Contracts;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); namespace API.Core;
// Add current controllers to the exception filter internal class Program
builder
.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{ {
options.AddSecurityDefinition( public static void Main(string[] args)
"Bearer", {
new OpenApiSecurityScheme WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
{
Name = "Authorization", builder.Logging.ClearProviders().AddConsole();
Type = SecuritySchemeType.Http,
Scheme = "Bearer", if (!builder.Environment.IsProduction())
BearerFormat = "JWT", builder.Logging.AddDebug();
In = ParameterLocation.Header,
Description = "Enter a JWT access token.", builder
} .Services.AddApiControllers()
); .AddValidationAndMediatR()
options.AddSecurityRequirement( .AddApplicationServices()
new OpenApiSecurityRequirement .AddFeatureModules()
{ .AddCoreInfrastructure()
{ .AddJwtAuthentication();
new OpenApiSecurityScheme
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 string? userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
{ string? username = user.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
Type = ReferenceType.SecurityScheme,
Id = "Bearer",
},
},
[]
},
}
);
});
builder.Services.AddOpenApi();
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); return Results.Ok(
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); new ResponseBody<object>
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); {
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly); Message = "Protected endpoint accessed successfully",
builder.Services.AddFluentValidationAutoValidation(); Payload = new { userId, username },
}
);
}
).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser());
// Add MediatR. // Easter egg, as per tradition with the previous versions of the Biergarten App.
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers. app.Map(
builder.Services.AddMediatR(cfg => "/teapot",
{ () =>
cfg.RegisterServicesFromAssemblyContaining<Program>(); Results.Json(
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); new ResponseBody
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); {
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); Message =
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); "I'm a little teapot, short and stout. Here is my handle, here is my spout!",
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); },
}); 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 app.Services.GetRequiredService<IHostApplicationLifetime>()
builder.Logging.ClearProviders(); .ApplicationStopping.Register(() =>
builder.Logging.AddConsole(); {
if (!builder.Environment.IsProduction()) app.Logger.LogInformation("Application is shutting down gracefully...");
builder.Logging.AddDebug(); });
builder.Services.AddDatabaseConnection(); app.Run();
}
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<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>();
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("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<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Application is shutting down gracefully...");
});
app.Run();

View File

@@ -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<GlobalExceptionFilter>())
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly)
.Services;
public IServiceCollection AddValidationAndMediatR()
{
services
.AddOpenApi()
.AddValidatorsFromAssemblyContaining<Program>()
.AddValidatorsFromAssembly(typeof(BreweryController).Assembly)
.AddValidatorsFromAssembly(typeof(AuthController).Assembly)
.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly)
.AddFluentValidationAutoValidation()
.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
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<ITokenInfrastructure, JwtInfrastructure>()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<GlobalExceptionFilter>();
public IServiceCollection AddJwtAuthentication()
{
services
.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
"JWT",
options => { }
);
services.AddAuthorization();
return services;
}
}
}

View File

@@ -7,4 +7,4 @@ Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I send an HTTP request "GET" to "/invalid-route" When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found." And the response JSON should have "message" equal "Are you lost? That route does not exist."

View File

@@ -15,7 +15,7 @@ public abstract class ApiStepsBase(ScenarioContext scenario)
protected const string ResponseKey = "response"; protected const string ResponseKey = "response";
protected const string ResponseBodyKey = "responseBody"; protected const string ResponseBodyKey = "responseBody";
protected TestApiFactory GetFactory() internal TestApiFactory GetFactory()
{ {
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,

View File

@@ -1,3 +1,4 @@
using API.Core;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Infrastructure.Email; using Infrastructure.Email;
@@ -7,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace API.Specs; namespace API.Specs;
public class TestApiFactory : WebApplicationFactory<Program> internal class TestApiFactory : WebApplicationFactory<Program>
{ {
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {