feat(udt-001): api layer with AuthController, Program.cs and Serilog
This commit is contained in:
48
src/api/SIGCM2.Api/Controllers/AuthController.cs
Normal file
48
src/api/SIGCM2.Api/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Auth.Login;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/auth")]
|
||||||
|
public sealed class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<LoginCommand> _validator;
|
||||||
|
|
||||||
|
public AuthController(IDispatcher dispatcher, IValidator<LoginCommand> validator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Authenticates a user and returns a JWT access token.</summary>
|
||||||
|
/// <response code="200">Returns access token and refresh token.</response>
|
||||||
|
/// <response code="400">Validation error — missing or empty fields.</response>
|
||||||
|
/// <response code="401">Invalid credentials.</response>
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
|
{
|
||||||
|
var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty);
|
||||||
|
|
||||||
|
var validation = await _validator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<LoginCommand, LoginResponseDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
|
||||||
|
public sealed record LoginRequest(string? Username, string? Password);
|
||||||
50
src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
Normal file
50
src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Filters;
|
||||||
|
|
||||||
|
public sealed class ExceptionFilter : IExceptionFilter
|
||||||
|
{
|
||||||
|
private readonly ILogger<ExceptionFilter> _logger;
|
||||||
|
|
||||||
|
public ExceptionFilter(ILogger<ExceptionFilter> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnException(ExceptionContext context)
|
||||||
|
{
|
||||||
|
switch (context.Exception)
|
||||||
|
{
|
||||||
|
case InvalidCredentialsException:
|
||||||
|
context.Result = new ObjectResult(new { error = "Credenciales inválidas" })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status401Unauthorized
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ValidationException validationEx:
|
||||||
|
var errors = validationEx.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
|
||||||
|
context.Result = new BadRequestObjectResult(new { errors });
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_logger.LogError(context.Exception, "Unhandled exception");
|
||||||
|
context.Result = new ObjectResult(new { error = "Internal server error" })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status500InternalServerError
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/api/SIGCM2.Api/Program.cs
Normal file
69
src/api/SIGCM2.Api/Program.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
using SIGCM2.Application;
|
||||||
|
using SIGCM2.Infrastructure;
|
||||||
|
using SIGCM2.Api.Filters;
|
||||||
|
|
||||||
|
// Bootstrap logger — before DI is built
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
|
Log.Information("Starting SIGCM2 API");
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Serilog — reads from appsettings.json "Serilog" section
|
||||||
|
builder.Host.UseSerilog((ctx, lc) => lc
|
||||||
|
.ReadFrom.Configuration(ctx.Configuration));
|
||||||
|
|
||||||
|
// Application + Infrastructure DI
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
|
// Controllers with exception filter
|
||||||
|
builder.Services.AddControllers(opts =>
|
||||||
|
{
|
||||||
|
opts.Filters.Add<ExceptionFilter>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenAPI / Scalar
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
var allowedOrigins = builder.Configuration
|
||||||
|
.GetSection("Cors:AllowedOrigins")
|
||||||
|
.Get<string[]>() ?? [];
|
||||||
|
|
||||||
|
builder.Services.AddCors(opts =>
|
||||||
|
{
|
||||||
|
opts.AddDefaultPolicy(policy =>
|
||||||
|
policy.WithOrigins(allowedOrigins)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod());
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Middleware pipeline
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference(opts =>
|
||||||
|
{
|
||||||
|
opts.Title = "SIGCM2 API";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
// Exposed for WebApplicationFactory in integration tests
|
||||||
|
public partial class Program { }
|
||||||
23
src/api/SIGCM2.Api/Properties/launchSettings.json
Normal file
23
src/api/SIGCM2.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5212",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7280;http://localhost:5212",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/api/SIGCM2.Api/SIGCM2.Api.csproj
Normal file
25
src/api/SIGCM2.Api/SIGCM2.Api.csproj
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>SIGCM2.Api</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Seq" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
|
<PackageReference Include="FluentValidation.AspNetCore" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
src/api/SIGCM2.Api/SIGCM2.Api.http
Normal file
6
src/api/SIGCM2.Api/SIGCM2.Api.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@SIGCM2.Api_HostAddress = http://localhost:5212
|
||||||
|
|
||||||
|
GET {{SIGCM2.Api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
13
src/api/SIGCM2.Api/appsettings.Development.json.example
Normal file
13
src/api/SIGCM2.Api/appsettings.Development.json.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"SqlServer": "Server=<YOUR_SERVER>;Database=SIGCM2;User Id=<YOUR_USER>;Password=<YOUR_PASSWORD>;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/api/SIGCM2.Api/appsettings.json
Normal file
35
src/api/SIGCM2.Api/appsettings.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"SqlServer": "Server=__SET_IN_DEV_OR_ENV__;Database=SIGCM2;User Id=__SET_IN_DEV_OR_ENV__;Password=__SET_IN_DEV_OR_ENV__;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "sigcm2.api",
|
||||||
|
"Audience": "sigcm2.web",
|
||||||
|
"AccessTokenMinutes": 60,
|
||||||
|
"PrivateKeyPath": "keys/private.pem",
|
||||||
|
"PublicKeyPath": "keys/public.pem",
|
||||||
|
"PrivateKey": null,
|
||||||
|
"PublicKey": null
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [ "http://localhost:5173" ]
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{
|
||||||
|
"Name": "Seq",
|
||||||
|
"Args": { "serverUrl": "http://localhost:5341" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
0
src/api/SIGCM2.Api/keys/.gitkeep
Normal file
0
src/api/SIGCM2.Api/keys/.gitkeep
Normal file
Reference in New Issue
Block a user