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