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

@@ -7,6 +7,10 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="API.Specs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<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.
/// </summary>
/// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable.
/// 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.
/// </remarks>
protected override async Task<AuthenticateResult> 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");

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>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{
/// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
/// </summary>
/// <remarks>
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
/// </remarks>
public void OnException(ExceptionContext context)
/// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
/// </summary>
/// <remarks>
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
/// </remarks>
public void OnException(ExceptionContext context)
{
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 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<GlobalExceptionFilter>(); })
.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<Program>();
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<object>
{
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<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<,>));
});
// 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<IHostApplicationLifetime>()
.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<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();
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
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."
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 ResponseBodyKey = "responseBody";
protected TestApiFactory GetFactory()
internal TestApiFactory GetFactory()
{
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
FactoryKey,

View File

@@ -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<Program>
internal class TestApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{