Implementar autenticación JWT con caché de tokens de Dolibarr por sesión
- Añadir autenticación estándar JWT Bearer con ASP.NET Core - Crear DolibarrTokenCacheService para la lógica de caché de tokens - Refactorizar DolibarrApiClient para usar TokenCacheService con SRP limpio - Actualizar Program.cs con registros de servicios y comentarios apropiados - Añadir AuthController para el endpoint de login - Actualizar InvoiceService para usar DolibarrApiClient
This commit is contained in:
parent
a80d41feab
commit
283a9e7f5f
|
|
@ -0,0 +1,355 @@
|
||||||
|
# AGENTS.md - Development Guidelines for DoliMiddlewareApi
|
||||||
|
|
||||||
|
This file contains essential information for AI coding agents working on the DoliMiddlewareApi project. Follow these guidelines to maintain consistency and quality.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
DoliMiddlewareApi is a C# ASP.NET Core Web API (targeting .NET 10.0) that serves as a middleware between client applications and Dolibarr ERP system. It provides invoice management functionality with RESTful endpoints.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
# Build in Debug mode (default)
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Build in Release mode
|
||||||
|
dotnet build -c Release
|
||||||
|
|
||||||
|
# Clean and build
|
||||||
|
dotnet clean && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
```bash
|
||||||
|
# Run the application (includes hot reload in development)
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# Run with specific configuration
|
||||||
|
dotnet run --environment Development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting/Formatting
|
||||||
|
```bash
|
||||||
|
# Format code according to project standards
|
||||||
|
dotnet format
|
||||||
|
|
||||||
|
# Check formatting without making changes
|
||||||
|
dotnet format --verify-no-changes
|
||||||
|
|
||||||
|
# Format whitespace only
|
||||||
|
dotnet format whitespace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
**Note**: This project currently has no test framework configured. When adding tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add xUnit test framework (recommended)
|
||||||
|
dotnet add package xunit
|
||||||
|
dotnet add package Microsoft.NET.Test.Sdk
|
||||||
|
dotnet add package xunit.runner.visualstudio
|
||||||
|
|
||||||
|
# Run tests (after setup)
|
||||||
|
dotnet test
|
||||||
|
|
||||||
|
# Run tests with coverage (after adding coverlet.collector)
|
||||||
|
dotnet test --collect:"XPlat Code Coverage"
|
||||||
|
|
||||||
|
# Run a specific test
|
||||||
|
dotnet test --filter "TestMethodName"
|
||||||
|
|
||||||
|
# Run tests in a specific class
|
||||||
|
dotnet test --filter "ClassName"
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
dotnet watch test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t dolimiddlewareapi .
|
||||||
|
|
||||||
|
# Run Docker container
|
||||||
|
docker run -p 8080:8080 dolimiddlewareapi
|
||||||
|
|
||||||
|
# Run with environment variables
|
||||||
|
docker run -p 8080:8080 -e ASPNETCORE_ENVIRONMENT=Development dolimiddlewareapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Language Features & Project Setup
|
||||||
|
- **Target Framework**: .NET 10.0
|
||||||
|
- **Nullable Reference Types**: Enabled - use `?` for nullable types, avoid `!` suppressions
|
||||||
|
- **Implicit Usings**: Enabled - core namespaces are automatically imported
|
||||||
|
- **Top-level Statements**: Not used (traditional Program.cs structure)
|
||||||
|
- **Authentication**: JWT Bearer tokens with session-based caching
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Classes**: PascalCase (e.g., `InvoiceService`, `CreateInvoiceDto`)
|
||||||
|
- **Methods**: PascalCase (e.g., `GetInvoiceAsync`, `MapToInvoiceDto`)
|
||||||
|
- **Properties**: PascalCase (e.g., `ClientId`, `TotalAmount`)
|
||||||
|
- **Private Fields**: camelCase with underscore prefix (e.g., `_httpClient`, `_invoiceService`)
|
||||||
|
- **Constants**: PascalCase (e.g., `DefaultPageSize`)
|
||||||
|
- **Namespaces**: Follow folder structure (e.g., `DoliMiddlewareApi.Controllers`)
|
||||||
|
- **Files**: Match class name (e.g., `InvoiceService.cs`)
|
||||||
|
|
||||||
|
### Imports & Using Statements
|
||||||
|
```csharp
|
||||||
|
// Group usings by:
|
||||||
|
// 1. System namespaces
|
||||||
|
// 2. Microsoft namespaces
|
||||||
|
// 3. Third-party packages
|
||||||
|
// 4. Project namespaces
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DoliMiddlewareApi.Dtos;
|
||||||
|
using DoliMiddlewareApi.Services;
|
||||||
|
|
||||||
|
// Remove unused usings automatically with: dotnet format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formatting & Structure
|
||||||
|
- **Indentation**: 4 spaces (follow .editorconfig when created)
|
||||||
|
- **Braces**: K&R style (opening brace on same line)
|
||||||
|
- **Line Length**: Aim for 100-120 characters, break long lines appropriately
|
||||||
|
- **File Structure**: One class per file, except for small related classes
|
||||||
|
|
||||||
|
### Controller Guidelines
|
||||||
|
```csharp
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize] // JWT authentication required
|
||||||
|
public class InvoicesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly InvoiceService _invoiceService;
|
||||||
|
|
||||||
|
// Constructor injection only
|
||||||
|
public InvoicesController(InvoiceService invoiceService)
|
||||||
|
{
|
||||||
|
_invoiceService = invoiceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ProducesResponseType for all endpoints
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<InvoiceDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<List<InvoiceDto>>> GetInvoices() => Ok(await _invoiceService.GetInvoicesAsync());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Layer Guidelines
|
||||||
|
```csharp
|
||||||
|
public class InvoiceService
|
||||||
|
{
|
||||||
|
private readonly DolibarrApiClient _apiClient;
|
||||||
|
|
||||||
|
public InvoiceService(DolibarrApiClient apiClient)
|
||||||
|
{
|
||||||
|
_apiClient = apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async all the way - use Task for all public methods
|
||||||
|
public async Task<InvoiceDetailDto> GetInvoiceAsync(int id)
|
||||||
|
{
|
||||||
|
var data = await _apiClient.GetResourceAsync<InvoiceDetailResponse>($"invoices/{id}");
|
||||||
|
return InvoiceMapper.MapToInvoiceDetailDto(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DTO Guidelines
|
||||||
|
```csharp
|
||||||
|
// Command DTOs (input)
|
||||||
|
public class CreateInvoiceDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int ClientId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
|
// Use meaningful defaults for optional properties
|
||||||
|
public string Status { get; set; } = "draft";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(1)]
|
||||||
|
public List<CreateInvoiceLineDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query DTOs (output) - handle nulls properly with nullable enabled
|
||||||
|
public class InvoiceDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Number { get; set; } = ""; // Provide defaults for non-nullable properties
|
||||||
|
public DateTime? Date { get; set; }
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
```csharp
|
||||||
|
// Custom exceptions for business logic
|
||||||
|
public class NotFoundException : Exception
|
||||||
|
{
|
||||||
|
public NotFoundException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use specific exceptions in services
|
||||||
|
if (invoice == null)
|
||||||
|
throw new NotFoundException($"Invoice with id {id} not found");
|
||||||
|
|
||||||
|
// Global error handling in Program.cs provides consistent API responses
|
||||||
|
// Logs errors appropriately based on type (expected vs unexpected)
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Client Usage
|
||||||
|
```csharp
|
||||||
|
// Use typed clients registered in DI
|
||||||
|
builder.Services.AddHttpClient<DolibarrApiClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!);
|
||||||
|
client.DefaultRequestHeaders.Add("DOLAPIKEY", builder.Configuration["Dolibarr:ApiKey"]!);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic methods for common operations
|
||||||
|
public async Task<T> GetResourceAsync<T>(string endpoint) where T : class
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(endpoint);
|
||||||
|
await EnsureSuccessOrThrowAsync(response, endpoint);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>()
|
||||||
|
?? throw new ApiException($"Failed to deserialize response from Dolibarr for endpoint '{endpoint}'");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Serialization
|
||||||
|
```csharp
|
||||||
|
// Configure in Program.cs for consistency
|
||||||
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
// Enums as strings
|
||||||
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
|
// camelCase properties
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mapping Guidelines
|
||||||
|
```csharp
|
||||||
|
// Static mapper classes
|
||||||
|
public static class InvoiceMapper
|
||||||
|
{
|
||||||
|
public static InvoiceDto MapToInvoiceDto(InvoiceResponse response)
|
||||||
|
{
|
||||||
|
return new InvoiceDto
|
||||||
|
{
|
||||||
|
Id = int.TryParse(response.id, out int id) ? id : 0,
|
||||||
|
Number = response.@ref ?? "SIN-REF",
|
||||||
|
// Handle parsing failures gracefully
|
||||||
|
Total = decimal.TryParse(response.total_ttc, NumberStyles.Any, CultureInfo.InvariantCulture,
|
||||||
|
out decimal total) ? Math.Round(total, 2) : null,
|
||||||
|
Status = ConvertStatusToWord(response.statut)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods for complex conversions
|
||||||
|
private static string ConvertStatusToWord(string? statusNumber)
|
||||||
|
{
|
||||||
|
return statusNumber switch
|
||||||
|
{
|
||||||
|
"0" => "draft",
|
||||||
|
"1" => "unpaid",
|
||||||
|
"2" => "paid",
|
||||||
|
"3" => "cancelled",
|
||||||
|
_ => "unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
```csharp
|
||||||
|
// Strongly typed configuration classes
|
||||||
|
public class DolibarrSettings
|
||||||
|
{
|
||||||
|
public string ApiUrl { get; set; } = "";
|
||||||
|
public string ApiKey { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register in Program.cs
|
||||||
|
builder.Services.Configure<DolibarrSettings>(builder.Configuration.GetSection("Dolibarr"));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Never log sensitive data (API keys, passwords)
|
||||||
|
- Use proper validation attributes on DTOs
|
||||||
|
- Validate all input parameters
|
||||||
|
- Use HTTPS in production
|
||||||
|
- Implement proper authentication/authorization when needed
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Use async/await throughout the stack
|
||||||
|
- Avoid blocking calls in async methods
|
||||||
|
- Use dependency injection for proper lifetime management
|
||||||
|
- Consider response caching for read operations
|
||||||
|
- Use pagination for list endpoints
|
||||||
|
|
||||||
|
### Logging Guidelines
|
||||||
|
```csharp
|
||||||
|
// Use structured logging with semantic parameters
|
||||||
|
_logger.LogInformation("Processing invoice creation for client {ClientId}", clientId);
|
||||||
|
|
||||||
|
// Log levels:
|
||||||
|
// - Information: Normal operations
|
||||||
|
// - Warning: Expected errors (business logic failures)
|
||||||
|
// - Error: Unexpected errors (bugs)
|
||||||
|
// - Debug: Detailed debugging information (development only)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Before making changes**: Run `dotnet build` to ensure current state compiles
|
||||||
|
2. **Make changes**: Follow the established patterns and conventions
|
||||||
|
3. **Format code**: Run `dotnet format` to ensure consistent formatting
|
||||||
|
4. **Test changes**: Run the application and test endpoints manually
|
||||||
|
5. **Build verification**: Run `dotnet build -c Release` before committing
|
||||||
|
|
||||||
|
## Architecture Patterns Used
|
||||||
|
|
||||||
|
- **Clean Architecture**: Controllers → Services → External APIs
|
||||||
|
- **Dependency Injection**: Constructor injection throughout
|
||||||
|
- **Repository Pattern**: Not implemented (direct API calls)
|
||||||
|
- **CQRS**: Separated command/query DTOs
|
||||||
|
- **Mapper Pattern**: Static mappers for data transformation
|
||||||
|
- **Exception Handling**: Global error handling middleware
|
||||||
|
- **Service Layer Separation**: Each service has a single responsibility (SRP)
|
||||||
|
- `DolibarrTokenCacheService`: Token caching (no HTTP calls)
|
||||||
|
- `DolibarrApiClient`: HTTP calls only (no cache logic)
|
||||||
|
- `InvoiceService`: Business logic (uses cached tokens via HTTP client)
|
||||||
|
- `AuthApplicationService`: Orchestrates login + token caching
|
||||||
|
- `DolibarrAuthService`: Authenticates with external API
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
When adding tests:
|
||||||
|
- Use xUnit as the testing framework
|
||||||
|
- Add integration tests for API endpoints
|
||||||
|
- Mock external API calls using Moq or NSubstitute
|
||||||
|
- Aim for high test coverage on business logic
|
||||||
|
|
||||||
|
When adding authentication:
|
||||||
|
- JWT token validation with Microsoft.AspNetCore.Authentication.JwtBearer
|
||||||
|
- Session-based caching for Dolibarr API tokens (handled in DolibarrTokenCacheService)
|
||||||
|
- [Authorize] attributes on protected controllers
|
||||||
|
|
||||||
|
When scaling:
|
||||||
|
- Consider adding response caching
|
||||||
|
- Implement rate limiting
|
||||||
|
- Add request/response logging middleware
|
||||||
|
- Consider API versioning strategy</content>
|
||||||
|
<parameter name="filePath">AGENTS.md
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
using DoliMiddlewareApi.Dtos.command;
|
||||||
|
using DoliMiddlewareApi.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DoliMiddlewareApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AuthController(AuthApplicationService authAppService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Login([FromBody] CreateTokenDto dto)
|
||||||
|
{
|
||||||
|
var result = await authAppService.LoginAsync(dto);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,14 @@ using System.ComponentModel.DataAnnotations;
|
||||||
using DoliMiddlewareApi.Dtos;
|
using DoliMiddlewareApi.Dtos;
|
||||||
using DoliMiddlewareApi.Dtos.command;
|
using DoliMiddlewareApi.Dtos.command;
|
||||||
using DoliMiddlewareApi.Services;
|
using DoliMiddlewareApi.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace DoliMiddlewareApi.Controllers;
|
namespace DoliMiddlewareApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
public class InvoicesController : ControllerBase
|
public class InvoicesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly InvoiceService _invoiceService;
|
private readonly InvoiceService _invoiceService;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,115 @@
|
||||||
using DoliMiddlewareApi.Exceptions;
|
using DoliMiddlewareApi.Exceptions;
|
||||||
using DoliMiddlewareApi.Services;
|
using DoliMiddlewareApi.Services;
|
||||||
using DoliMiddlewareApi.Services.Clients;
|
using DoliMiddlewareApi.Services.Clients;
|
||||||
|
using DoliMiddlewareApi.Services.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS
|
||||||
|
// =========================================
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
if (string.IsNullOrEmpty(builder.Configuration["Jwt:Secret"]))
|
||||||
|
throw new InvalidOperationException("JWT Secret is required in configuration. Set 'Jwt:Secret' in appsettings.json or environment variables.");
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// 1. CONFIGURACIÓN DE SERVICIOS ASP.NET CORE
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
// Controllers + JSON (camelCase + enums as strings)
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
// Serializar enums como strings en vez de números
|
|
||||||
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
|
||||||
// Usar camelCase para propiedades (id en vez de Id)
|
|
||||||
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
});
|
});
|
||||||
// Swagger/OpenAPI con Swashbuckle (genera schemas completos)
|
|
||||||
|
// JWT Standard (ASP.NET auto-validation)
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "DoliMiddleware",
|
||||||
|
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "DoliClients",
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// Swagger/OpenAPI
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
|
||||||
// Typed Client - Inyecta HttpClient directamente en DolibarrApiClient
|
// 2. DOLIBARR HTTP CLIENT
|
||||||
// Registra la interfaz para poder hacer mock en tests
|
// =========================================
|
||||||
builder.Services.AddHttpClient<IDolibarrApiClient, DolibarrApiClient>(client =>
|
// HttpClient named para Dolibarr (configurado solo con BaseAddress)
|
||||||
|
// DolibarrApiClient se crea manual porque necesita HttpClient + TokenCacheService
|
||||||
|
//
|
||||||
|
// POR QUÉ NO usar AddHttpClient<TClient>() directamente?
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// HttpClient es COMPARTIDO entre todos los usuarios
|
||||||
|
// Si configuramos el token ahí, el Usuario A usaría el token del Usuario B
|
||||||
|
// Solución: TokenCacheService obtiene el token del USUARIO ACTUAL (del JWT)
|
||||||
|
// factory.CreateClient("Dolibarr") devuelve el MISMO HttpClient reusado
|
||||||
|
// No se crea un cliente por request, los headers son por request (thread-safe)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient("Dolibarr", client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!);
|
client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!);
|
||||||
client.DefaultRequestHeaders.Add("DOLAPIKEY", builder.Configuration["Dolibarr:ApiKey"]!);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Registrar servicios de negocio
|
builder.Services.AddScoped<IDolibarrApiClient>(sp =>
|
||||||
|
{
|
||||||
|
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||||
|
var client = factory.CreateClient("Dolibarr");
|
||||||
|
var tokenCacheService = sp.GetRequiredService<DolibarrTokenCacheService>();
|
||||||
|
return new DolibarrApiClient(client, tokenCacheService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// 3. REGISTRO DE SERVICIOS (DEPENDENCIAS)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
// Servicio de negocio (facturas)
|
||||||
builder.Services.AddScoped<InvoiceService>();
|
builder.Services.AddScoped<InvoiceService>();
|
||||||
|
|
||||||
|
// Servicio de aplicación (orquesta login + cache)
|
||||||
|
builder.Services.AddScoped<AuthApplicationService>();
|
||||||
|
|
||||||
|
// Servicio de autenticación con Dolibarr
|
||||||
|
builder.Services.AddScoped<DolibarrAuthService>();
|
||||||
|
|
||||||
|
// Servicio de cache de tokens de Dolibarr
|
||||||
|
builder.Services.AddScoped<DolibarrTokenCacheService>();
|
||||||
|
|
||||||
|
// Generador de JWT - Singleton porque es stateless
|
||||||
|
builder.Services.AddSingleton<JwtTokenProvider>();
|
||||||
|
|
||||||
|
// Cache en memoria para tokens (IMemoryCache)
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
// HttpContext accessor para acceder a User.Claims en servicios
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Global exception handler
|
// =========================================
|
||||||
|
// 4. GLOBAL EXCEPTION HANDLER
|
||||||
|
// =========================================
|
||||||
|
|
||||||
app.UseExceptionHandler(errorApp =>
|
app.UseExceptionHandler(errorApp =>
|
||||||
{
|
{
|
||||||
errorApp.Run(async context =>
|
errorApp.Run(async context =>
|
||||||
|
|
@ -44,44 +119,36 @@ app.UseExceptionHandler(errorApp =>
|
||||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||||
var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
|
var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
|
||||||
// PASO 1: Clasificar el tipo de error
|
// Clasificar el tipo de error
|
||||||
var (statusCode, title, detail, isExpectedError) = exception switch
|
var (statusCode, title, detail, isExpectedError) = exception switch
|
||||||
{
|
{
|
||||||
// ERRORES ESPERADOS (del negocio/cliente) - OK mostrar detalles
|
|
||||||
NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true),
|
NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true),
|
||||||
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true),
|
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true),
|
||||||
ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true),
|
ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true),
|
||||||
BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.Message, true),
|
BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.Message, true),
|
||||||
|
|
||||||
// ERROR DE API EXTERNA (Dolibarr) - Mostrar que es externo pero no detalles internos
|
|
||||||
ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error",
|
ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error",
|
||||||
env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false),
|
env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false),
|
||||||
|
|
||||||
// ERRORES INESPERADOS (bugs del programador)
|
|
||||||
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error",
|
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error",
|
||||||
env.IsDevelopment()
|
env.IsDevelopment()
|
||||||
? exception?.Message ?? "An unexpected error occurred" // DESARROLLO: muestra el error real
|
? exception?.Message ?? "An unexpected error occurred"
|
||||||
: "An unexpected error occurred. Please try again later.", // PRODUCCIÓN: mensaje genérico
|
: "An unexpected error occurred. Please try again later.", false)
|
||||||
false)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// PASO 2: SIEMPRE loguear (crítico para debugging en producción)
|
// Loguear siempre (crítico para debugging)
|
||||||
if (isExpectedError)
|
if (isExpectedError)
|
||||||
{
|
{
|
||||||
// Errores esperados: log como Warning (no son bugs, son flujo normal)
|
|
||||||
logger.LogWarning(exception,
|
logger.LogWarning(exception,
|
||||||
"Expected error: {ExceptionType} | Path: {Path} | Message: {Message}",
|
"Expected error: {ExceptionType} | Path: {Path} | Message: {Message}",
|
||||||
exception?.GetType().Name, context.Request.Path, exception?.Message);
|
exception?.GetType().Name, context.Request.Path, exception?.Message);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Errores inesperados: log como Error (son bugs que HAY QUE ARREGLAR)
|
|
||||||
logger.LogError(exception,
|
logger.LogError(exception,
|
||||||
"UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}",
|
"UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}",
|
||||||
exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace);
|
exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASO 3: Construir respuesta ProblemDetails (estándar RFC 7807)
|
// Construir respuesta ProblemDetails (RFC 7807)
|
||||||
context.Response.StatusCode = statusCode;
|
context.Response.StatusCode = statusCode;
|
||||||
context.Response.ContentType = "application/problem+json";
|
context.Response.ContentType = "application/problem+json";
|
||||||
|
|
||||||
|
|
@ -93,7 +160,7 @@ app.UseExceptionHandler(errorApp =>
|
||||||
Instance = context.Request.Path
|
Instance = context.Request.Path
|
||||||
};
|
};
|
||||||
|
|
||||||
// PASO 4: EN DESARROLLO añadir info extra para debugging
|
// En desarrollo añadir info extra para debugging
|
||||||
if (env.IsDevelopment() && !isExpectedError)
|
if (env.IsDevelopment() && !isExpectedError)
|
||||||
{
|
{
|
||||||
problemDetails.Extensions["exceptionType"] = exception?.GetType().Name;
|
problemDetails.Extensions["exceptionType"] = exception?.GetType().Name;
|
||||||
|
|
@ -109,7 +176,10 @@ app.UseExceptionHandler(errorApp =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// =========================================
|
||||||
|
// 5. PIPELINE ASP.NET CORE
|
||||||
|
// =========================================
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
|
|
@ -121,6 +191,7 @@ if (app.Environment.IsDevelopment())
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using DoliMiddlewareApi.Dtos.command;
|
using DoliMiddlewareApi.Dtos.command;
|
||||||
using DoliMiddlewareApi.Dtos.Dolibarr;
|
using DoliMiddlewareApi.Dtos.Dolibarr;
|
||||||
|
using DoliMiddlewareApi.Exceptions;
|
||||||
using DoliMiddlewareApi.Services.Clients;
|
using DoliMiddlewareApi.Services.Clients;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
|
@ -17,13 +18,13 @@ public sealed class DolibarrAuthService(IDolibarrApiClient apiClient)
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(responseString))
|
if (string.IsNullOrEmpty(responseString))
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("Credenciales inválidas");
|
throw new UnauthorizedException("Credenciales inválidas");
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = JsonSerializer.Deserialize<TokenResponse>(responseString);
|
var response = JsonSerializer.Deserialize<TokenResponse>(responseString);
|
||||||
if (response == null || string.IsNullOrEmpty(response.AccessToken))
|
if (response == null || string.IsNullOrEmpty(response.AccessToken))
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("Respuesta inválida de Dolibarr");
|
throw new UnauthorizedException("Respuesta inválida de Dolibarr");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.AccessToken;
|
return response.AccessToken;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace DoliMiddlewareApi.Services.Auth;
|
||||||
|
|
||||||
|
public class DolibarrTokenCacheService(IHttpContextAccessor httpContextAccessor, IMemoryCache cache)
|
||||||
|
{
|
||||||
|
|
||||||
|
public string? GetDolibarrToken()
|
||||||
|
{
|
||||||
|
var context = httpContextAccessor.HttpContext;
|
||||||
|
if (context?.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
var sessionIdClaim = context.User.Claims.FirstOrDefault(c => c.Type == "sessionId");
|
||||||
|
if (sessionIdClaim != null && cache.TryGetValue(sessionIdClaim.Value, out string? dolibarrToken))
|
||||||
|
{
|
||||||
|
return dolibarrToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDolibarrToken(string sessionId, string dolibarrToken, TimeSpan expiration)
|
||||||
|
{
|
||||||
|
cache.Set(sessionId, dolibarrToken, expiration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DoliMiddlewareApi.Services.Auth;
|
||||||
|
|
||||||
|
public sealed class JwtTokenProvider(IConfiguration config)
|
||||||
|
{
|
||||||
|
public string GenerateJwt(string sessionId, string username)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim("sessionId", sessionId),
|
||||||
|
new Claim(ClaimTypes.Name, username)
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: config["Jwt:Issuer"] ?? "DoliMiddleware",
|
||||||
|
audience: config["Jwt:Audience"] ?? "DoliClients",
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.Now.AddMinutes(30),
|
||||||
|
signingCredentials: creds
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
using DoliMiddlewareApi.Dtos.command;
|
||||||
|
using DoliMiddlewareApi.Services.Auth;
|
||||||
|
|
||||||
|
namespace DoliMiddlewareApi.Services;
|
||||||
|
|
||||||
|
public class AuthApplicationService(DolibarrAuthService dolibarrAuth, JwtTokenProvider jwtProvider, DolibarrTokenCacheService tokenCacheService)
|
||||||
|
{
|
||||||
|
public async Task<LoginResponse> LoginAsync(CreateTokenDto dto)
|
||||||
|
{
|
||||||
|
var doliToken = await dolibarrAuth.AuthenticateAsync(dto);
|
||||||
|
var sessionId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
tokenCacheService.SetDolibarrToken(sessionId, doliToken, TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
|
var jwt = jwtProvider.GenerateJwt(sessionId, dto.Username);
|
||||||
|
|
||||||
|
return new LoginResponse { Token = jwt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LoginResponse
|
||||||
|
{
|
||||||
|
public required string Token { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using DoliMiddlewareApi.Exceptions;
|
using DoliMiddlewareApi.Exceptions;
|
||||||
|
using DoliMiddlewareApi.Services.Auth;
|
||||||
|
|
||||||
namespace DoliMiddlewareApi.Services.Clients;
|
namespace DoliMiddlewareApi.Services.Clients;
|
||||||
|
|
||||||
public class DolibarrApiClient : IDolibarrApiClient
|
public class DolibarrApiClient : IDolibarrApiClient
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly DolibarrTokenCacheService _tokenCacheService;
|
||||||
|
|
||||||
public DolibarrApiClient(HttpClient httpClient)
|
public DolibarrApiClient(HttpClient httpClient, DolibarrTokenCacheService tokenCacheService)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
_tokenCacheService = tokenCacheService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== MÉTODOS GENÉRICOS =====
|
// ===== MÉTODOS GENÉRICOS =====
|
||||||
public async Task<T> GetResourceAsync<T>(string endpoint) where T : class
|
public async Task<T> GetResourceAsync<T>(string endpoint) where T : class
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(endpoint);
|
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||||
|
AddDolibarrTokenHeader(request);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
await EnsureSuccessOrThrowAsync(response, endpoint);
|
await EnsureSuccessOrThrowAsync(response, endpoint);
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<T>()
|
return await response.Content.ReadFromJsonAsync<T>()
|
||||||
|
|
@ -25,7 +30,9 @@ public class DolibarrApiClient : IDolibarrApiClient
|
||||||
|
|
||||||
public async Task<List<T>> GetCollectionAsync<T>(string endpoint) where T : class
|
public async Task<List<T>> GetCollectionAsync<T>(string endpoint) where T : class
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(endpoint);
|
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||||
|
AddDolibarrTokenHeader(request);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
await EnsureSuccessOrThrowAsync(response, endpoint);
|
await EnsureSuccessOrThrowAsync(response, endpoint);
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<List<T>>()
|
return await response.Content.ReadFromJsonAsync<List<T>>()
|
||||||
|
|
@ -35,7 +42,12 @@ public class DolibarrApiClient : IDolibarrApiClient
|
||||||
|
|
||||||
public async Task<string> PostAsync(string endpoint, object requestBody)
|
public async Task<string> PostAsync(string endpoint, object requestBody)
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PostAsJsonAsync(endpoint, requestBody);
|
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(requestBody)
|
||||||
|
};
|
||||||
|
AddDolibarrTokenHeader(request);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
await EnsureSuccessOrThrowAsync(response, endpoint);
|
await EnsureSuccessOrThrowAsync(response, endpoint);
|
||||||
|
|
||||||
// porque devuelve el id como response
|
// porque devuelve el id como response
|
||||||
|
|
@ -44,12 +56,26 @@ public class DolibarrApiClient : IDolibarrApiClient
|
||||||
|
|
||||||
public async Task<string> PutAsync(string endpoint, object requestBody)
|
public async Task<string> PutAsync(string endpoint, object requestBody)
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PutAsJsonAsync(endpoint, requestBody);
|
var request = new HttpRequestMessage(HttpMethod.Put, endpoint)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(requestBody)
|
||||||
|
};
|
||||||
|
AddDolibarrTokenHeader(request);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
await EnsureSuccessOrThrowAsync(response, endpoint);
|
await EnsureSuccessOrThrowAsync(response, endpoint);
|
||||||
|
|
||||||
return await response.Content.ReadAsStringAsync();
|
return await response.Content.ReadAsStringAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddDolibarrTokenHeader(HttpRequestMessage request)
|
||||||
|
{
|
||||||
|
var dolibarrToken = _tokenCacheService.GetDolibarrToken();
|
||||||
|
if (!string.IsNullOrEmpty(dolibarrToken))
|
||||||
|
{
|
||||||
|
request.Headers.Add("DOLAPIKEY", dolibarrToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,10 @@
|
||||||
"Dolibarr": {
|
"Dolibarr": {
|
||||||
"ApiUrl": "",
|
"ApiUrl": "",
|
||||||
"ApiKey": ""
|
"ApiKey": ""
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion",
|
||||||
|
"Issuer": "DoliMiddleware",
|
||||||
|
"Audience": "DoliClients"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue