First Commit
This commit is contained in:
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public sealed class AdsClientValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string ClientId { get; set; } = default!;
|
||||
|
||||
public string? TenantId { get; set; }
|
||||
public List<Claim> ExtraClaims { get; set; } = new();
|
||||
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public interface IAdsClientCredentialValidator
|
||||
{
|
||||
Task<AdsClientValidationResult> ValidateAsync(
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
string? tenantId,
|
||||
string? scope,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ADS.Helpers.Auth.Models;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public interface IAdsPrincipalAccessor
|
||||
{
|
||||
AdsPrincipal Current { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using ADS.Helpers.Auth.Token;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public interface IAdsTokenService
|
||||
{
|
||||
Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public sealed class AdsUserValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public int CveUser { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
|
||||
public int CveRole { get; set; }
|
||||
public int CveEmpresa { get; set; }
|
||||
public int CveCliente { get; set; }
|
||||
|
||||
public int[] CveModulos { get; set; } = [];
|
||||
public string[] Permissions { get; set; } = [];
|
||||
|
||||
public List<Claim> ExtraClaims { get; set; } = new();
|
||||
|
||||
public string? Error { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface IAdsUserCredentialValidator
|
||||
{
|
||||
Task<AdsUserValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
string password,
|
||||
string? tenantId,
|
||||
string? scope,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ADS.Helpers.Auth.Claims;
|
||||
|
||||
public static class AdsClaimTypes
|
||||
{
|
||||
public const string CveUser = "ads:cve_user";
|
||||
public const string TenantId = "ads:tenant_id";
|
||||
public const string UserName = "ads:username";
|
||||
public const string CveRole = "ads:cve_role";
|
||||
public const string CveEmpresa = "ads:cve_empresa";
|
||||
public const string CveCliente = "ads:cve_cliente";
|
||||
public const string CveModulos = "ads:cve_modulos";
|
||||
public const string Permissions = "ads:permissions";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||
|
||||
public sealed class TokenRequest
|
||||
{
|
||||
public string ClientId { get; set; } = default!;
|
||||
public string ClientSecret { get; set; } = default!;
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||
|
||||
public sealed class TokenResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = default!;
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public int ExpiresIn { get; set; }
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Implementation;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ADS.Helpers.Auth.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsAuthentication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
var jwtSection = configuration.GetSection("Jwt");
|
||||
services.Configure<JwtOptions>(jwtSection);
|
||||
|
||||
var jwtOptions = jwtSection.Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("Jwt configuration missing.");
|
||||
|
||||
var key = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = jwtOptions.ValidateLifetime,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(jwtOptions.ClockSkewMinutes)
|
||||
};
|
||||
});
|
||||
|
||||
services.AddScoped<IAdsPrincipalAccessor, AdsPrincipalAccessor>();
|
||||
|
||||
// NUEVO: servicio emisor de token (requiere que el host registre validadores)
|
||||
services.AddScoped<IAdsTokenService, AdsTokenService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdsAuthorization(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Claims;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Claims;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ADS.Helpers.Auth.Implementation;
|
||||
|
||||
internal sealed class AdsPrincipalAccessor : IAdsPrincipalAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private AdsPrincipal? _cached;
|
||||
|
||||
public AdsPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated =>
|
||||
_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
|
||||
|
||||
public AdsPrincipal Current
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cached is not null)
|
||||
return _cached;
|
||||
|
||||
var user = _httpContextAccessor.HttpContext?.User
|
||||
?? throw new UnauthorizedAccessException("No HttpContext user.");
|
||||
|
||||
_cached = Map(user);
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static AdsPrincipal Map(ClaimsPrincipal user)
|
||||
{
|
||||
int ParseInt(string type) =>
|
||||
int.TryParse(user.FindFirst(type)?.Value, out var val) ? val : 0;
|
||||
|
||||
int[] ParseIntArray(string type) =>
|
||||
user.FindAll(type)
|
||||
.Select(x => int.TryParse(x.Value, out var v) ? v : 0)
|
||||
.Where(x => x > 0)
|
||||
.ToArray();
|
||||
|
||||
string[] ParseStringArray(string type) =>
|
||||
user.FindAll(type)
|
||||
.Select(x => x.Value)
|
||||
.ToArray();
|
||||
|
||||
return new AdsPrincipal
|
||||
{
|
||||
CveUser = ParseInt(AdsClaimTypes.CveUser),
|
||||
TenantId = AdsClaimTypes.TenantId,
|
||||
UserName = user.FindFirst(AdsClaimTypes.UserName)?.Value,
|
||||
CveRole = ParseInt(AdsClaimTypes.CveRole),
|
||||
CveEmpresa = ParseInt(AdsClaimTypes.CveEmpresa),
|
||||
CveCliente = ParseInt(AdsClaimTypes.CveCliente),
|
||||
CveModulos = ParseIntArray(AdsClaimTypes.CveModulos),
|
||||
Permissions = ParseStringArray(AdsClaimTypes.Permissions),
|
||||
Claims = user.Claims.ToDictionary(x => x.Type, x => x.Value)
|
||||
};
|
||||
}
|
||||
}
|
||||
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Claims;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using ADS.Helpers.Auth.Token;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ADS.Helpers.Auth.Implementation;
|
||||
|
||||
internal sealed class AdsTokenService : IAdsTokenService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
private readonly IAdsUserCredentialValidator _userValidator;
|
||||
private readonly IAdsClientCredentialValidator _clientValidator;
|
||||
|
||||
public AdsTokenService(
|
||||
IOptions<JwtOptions> options,
|
||||
IAdsUserCredentialValidator userValidator,
|
||||
IAdsClientCredentialValidator clientValidator)
|
||||
{
|
||||
_options = options.Value ?? throw new InvalidOperationException("JwtOptions missing.");
|
||||
_userValidator = userValidator ?? throw new ArgumentNullException(nameof(userValidator));
|
||||
_clientValidator = clientValidator ?? throw new ArgumentNullException(nameof(clientValidator));
|
||||
}
|
||||
|
||||
public async Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.GrantType)) throw new InvalidOperationException("GrantType is required.");
|
||||
|
||||
var grant = request.GrantType.Trim().ToLowerInvariant();
|
||||
|
||||
if (grant == AdsGrantTypes.Password)
|
||||
return await IssuePasswordAsync(request, ct);
|
||||
|
||||
if (grant == AdsGrantTypes.ClientCredentials)
|
||||
return await IssueClientCredentialsAsync(request, ct);
|
||||
|
||||
throw new InvalidOperationException($"Unsupported GrantType: {request.GrantType}");
|
||||
}
|
||||
|
||||
private async Task<AdsTokenResult> IssuePasswordAsync(AdsTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
throw new InvalidOperationException("UserName is required for password grant.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Password))
|
||||
throw new InvalidOperationException("Password is required for password grant.");
|
||||
|
||||
var validation = await _userValidator.ValidateAsync(
|
||||
request.UserName.Trim(),
|
||||
request.Password,
|
||||
request.TenantId,
|
||||
request.Scope,
|
||||
ct);
|
||||
|
||||
if (!validation.IsValid)
|
||||
throw new UnauthorizedAccessException(validation.Error ?? "Invalid credentials.");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(AdsClaimTypes.CveUser, validation.CveUser.ToString()),
|
||||
new(AdsClaimTypes.UserName, validation.UserName ?? request.UserName.Trim()),
|
||||
new(AdsClaimTypes.CveRole, validation.CveRole.ToString()),
|
||||
new(AdsClaimTypes.CveEmpresa, validation.CveEmpresa.ToString()),
|
||||
new(AdsClaimTypes.CveCliente, validation.CveCliente.ToString()),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||
|
||||
foreach (var m in validation.CveModulos.Where(x => x > 0))
|
||||
claims.Add(new Claim(AdsClaimTypes.CveModulos, m.ToString()));
|
||||
|
||||
foreach (var p in validation.Permissions.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
claims.Add(new Claim(AdsClaimTypes.Permissions, p));
|
||||
|
||||
if (validation.ExtraClaims.Count > 0)
|
||||
claims.AddRange(validation.ExtraClaims);
|
||||
|
||||
return BuildToken(claims, request.Scope);
|
||||
}
|
||||
|
||||
private async Task<AdsTokenResult> IssueClientCredentialsAsync(AdsTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||
throw new InvalidOperationException("ClientId is required for client_credentials grant.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ClientSecret))
|
||||
throw new InvalidOperationException("ClientSecret is required for client_credentials grant.");
|
||||
|
||||
var validation = await _clientValidator.ValidateAsync(
|
||||
request.ClientId.Trim(),
|
||||
request.ClientSecret,
|
||||
request.TenantId,
|
||||
request.Scope,
|
||||
ct);
|
||||
|
||||
if (!validation.IsValid)
|
||||
throw new UnauthorizedAccessException(validation.Error ?? "Invalid client credentials.");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(AdsClaimTypes.UserName, validation.ClientId),
|
||||
};
|
||||
|
||||
var tenantId = validation.TenantId ?? request.TenantId;
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||
|
||||
if (validation.ExtraClaims.Count > 0)
|
||||
claims.AddRange(validation.ExtraClaims);
|
||||
|
||||
return BuildToken(claims, request.Scope);
|
||||
}
|
||||
|
||||
private AdsTokenResult BuildToken(IReadOnlyCollection<Claim> claims, string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.SigningKey))
|
||||
throw new InvalidOperationException("JwtOptions.SigningKey is required.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expires = now.AddMinutes(_options.AccessTokenMinutes <= 0 ? 60 : _options.AccessTokenMinutes);
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: expires,
|
||||
signingCredentials: creds
|
||||
);
|
||||
|
||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||
|
||||
return new AdsTokenResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
TokenType = "Bearer",
|
||||
ExpiresIn = (int)Math.Max(0, (expires - now).TotalSeconds),
|
||||
Scope = scope
|
||||
};
|
||||
}
|
||||
|
||||
public Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default)
|
||||
{
|
||||
if (claims is null) throw new ArgumentNullException(nameof(claims));
|
||||
// ct no se usa, pero lo dejamos por consistencia
|
||||
return Task.FromResult(BuildToken(claims, scope));
|
||||
}
|
||||
|
||||
}
|
||||
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace ADS.Helpers.Auth.Models;
|
||||
|
||||
public sealed class AdsPrincipal
|
||||
{
|
||||
public int CveUser { get; init; }
|
||||
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
public string? UserName { get; init; }
|
||||
|
||||
public int CveRole { get; init; }
|
||||
|
||||
public int CveEmpresa { get; init; }
|
||||
|
||||
public int CveCliente { get; init; }
|
||||
|
||||
public int[] CveModulos { get; init; } = [];
|
||||
|
||||
public string[] Permissions { get; init; } = [];
|
||||
|
||||
public Dictionary<string, string> Claims { get; init; } = new();
|
||||
}
|
||||
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ADS.Helpers.Auth.Models;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public string Issuer { get; set; } = default!;
|
||||
public string Audience { get; set; } = default!;
|
||||
public string SigningKey { get; set; } = default!;
|
||||
public bool ValidateLifetime { get; set; } = true;
|
||||
public int ClockSkewMinutes { get; set; } = 5;
|
||||
public int AccessTokenMinutes { get; set; } = 60;
|
||||
}
|
||||
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ADS.Helpers.Auth.Token;
|
||||
|
||||
public static class AdsGrantTypes
|
||||
{
|
||||
public const string Password = "password";
|
||||
public const string ClientCredentials = "client_credentials";
|
||||
}
|
||||
|
||||
public sealed class AdsTokenRequest
|
||||
{
|
||||
public string GrantType { get; set; } = AdsGrantTypes.Password;
|
||||
|
||||
// password
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
|
||||
// client_credentials
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
// multitenant / contexto
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
// opcional: scope
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ADS.Helpers.Auth.Token;
|
||||
|
||||
public sealed class AdsTokenResult
|
||||
{
|
||||
public string AccessToken { get; set; } = default!;
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public int ExpiresIn { get; set; }
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
@@ -0,0 +1,44 @@
|
||||
9️⃣ appsettings.json ejemplo
|
||||
|
||||
"Jwt": {
|
||||
"Issuer": "ADS",
|
||||
"Audience": "ADS_APIS",
|
||||
"SigningKey": "SUPER_SECRET_KEY_CHANGE_THIS",
|
||||
"ValidateLifetime": true,
|
||||
"ClockSkewMinutes": 5
|
||||
}
|
||||
|
||||
|
||||
🔟 Cómo usarlo en el Host o API GPS
|
||||
|
||||
builder.Services.AddAdsAuthentication(builder.Configuration);
|
||||
builder.Services.AddAdsAuthorization();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
Y en cualquier controlador:
|
||||
|
||||
public class GpsController : ControllerBase
|
||||
{
|
||||
private readonly IAdsPrincipalAccessor _principal;
|
||||
|
||||
public GpsController(IAdsPrincipalAccessor principal)
|
||||
{
|
||||
_principal = principal;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
var user = _principal.Current;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
user.CveUser,
|
||||
user.TenantId,
|
||||
user.CveEmpresa,
|
||||
user.CveModulos
|
||||
});
|
||||
}
|
||||
}
|
||||
9
Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
Normal file
9
Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
8
Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
Normal file
8
Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ADS.Helpers.Base.Abstractions;
|
||||
|
||||
public interface IAppLogger
|
||||
{
|
||||
void Info(string source, string code, string message, object? data = null);
|
||||
void Warn(string source, string code, string message, object? data = null);
|
||||
void Error(string source, string code, string message, Exception? ex = null, object? data = null);
|
||||
}
|
||||
6
Helpers/ADS.Helpers.Base/Class1.cs
Normal file
6
Helpers/ADS.Helpers.Base/Class1.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Base;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
9
Helpers/ADS.Helpers.Base/readme.md
Normal file
9
Helpers/ADS.Helpers.Base/readme.md
Normal file
@@ -0,0 +1,9 @@
|
||||
✅ 4️⃣ En tu API (Host)
|
||||
|
||||
En Program.cs:
|
||||
|
||||
builder.Services.AddAdsLogging();
|
||||
builder.Services.AddAdsNotifyCenter();
|
||||
|
||||
|
||||
Y listo.
|
||||
18
Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
Normal file
18
Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Data;
|
||||
|
||||
namespace ADS.Helpers.Data.Abstractions;
|
||||
|
||||
public interface ITenantDbConnectionFactory
|
||||
{
|
||||
IDbConnection Create();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ADS.Helpers.Data.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ADS.Helpers.Data.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsTenantDb(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ITenantDbConnectionFactory, TenantDbConnectionFactory>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
26
Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
Normal file
26
Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Data;
|
||||
using ADS.Helpers.Data.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context; // o .Abstractions (según donde esté tu interfaz)
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ADS.Helpers.Data;
|
||||
|
||||
public sealed class TenantDbConnectionFactory : ITenantDbConnectionFactory
|
||||
{
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
|
||||
public TenantDbConnectionFactory(ITenantContextAccessor tenantAccessor)
|
||||
=> _tenantAccessor = tenantAccessor;
|
||||
|
||||
public IDbConnection Create()
|
||||
{
|
||||
var tenant = _tenantAccessor.Current
|
||||
?? throw new InvalidOperationException("TENANT_NOT_RESOLVED");
|
||||
|
||||
var cs = tenant.Database?.ConnectionString;
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
throw new InvalidOperationException("TENANT_CONNECTION_STRING_MISSING");
|
||||
|
||||
return new SqlConnection(cs);
|
||||
}
|
||||
}
|
||||
15
Helpers/ADS.Helpers.Data/readme.md
Normal file
15
Helpers/ADS.Helpers.Data/readme.md
Normal file
@@ -0,0 +1,15 @@
|
||||
7) Usarlo en cualquier micro (GPS incluido)
|
||||
|
||||
En el Program.cs del micro:
|
||||
|
||||
using ADS.Helpers.Data.Extensions;
|
||||
|
||||
builder.Services.AddAdsMiddlewares(builder.Configuration); // tu pipeline tenant
|
||||
builder.Services.AddAdsTenantDb();
|
||||
|
||||
|
||||
Y en repositorios (Dapper):
|
||||
|
||||
using ADS.Helpers.Data.Abstractions;
|
||||
|
||||
using var cn = _dbFactory.Create();
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Localization.Abstractions;
|
||||
|
||||
public interface IMessageCatalog
|
||||
{
|
||||
string Get(string source,string code, string culture);
|
||||
}
|
||||
6
Helpers/ADS.Helpers.Localization/Class1.cs
Normal file
6
Helpers/ADS.Helpers.Localization/Class1.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Localization;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ADS.Helpers.Localization.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ADS.Helpers.Localization.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsLocalization(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<InMemoryMessageCatalog>();
|
||||
services.AddSingleton<IMessageCatalog>(sp => sp.GetRequiredService<InMemoryMessageCatalog>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
28
Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
Normal file
28
Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ADS.Helpers.Localization.Abstractions;
|
||||
|
||||
namespace ADS.Helpers.Localization;
|
||||
|
||||
public sealed class InMemoryMessageCatalog : IMessageCatalog
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string source, string code, string culture), string> _messages = new();
|
||||
|
||||
public InMemoryMessageCatalog Seed(string source, string culture, IReadOnlyDictionary<string, string> messages)
|
||||
{
|
||||
foreach (var kv in messages)
|
||||
_messages[(source, kv.Key, culture)] = kv.Value;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public string Get(string source, string code, string culture)
|
||||
{
|
||||
if (_messages.TryGetValue((source, code, culture), out var msg))
|
||||
return msg;
|
||||
|
||||
if (_messages.TryGetValue((source, code, "en-US"), out var en))
|
||||
return en;
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
22
Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
Normal file
22
Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ADS.Helpers.Middleware.ADSException;
|
||||
|
||||
using ADS.Helpers.Middleware.Http;
|
||||
|
||||
public sealed class AdsExceptionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IAdsErrorLogger _logger;
|
||||
|
||||
public AdsExceptionMiddleware(RequestDelegate next, IAdsErrorLogger logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (System.UnauthorizedAccessException ex)
|
||||
{
|
||||
await Handle(context, HttpStatusCode.Unauthorized, ex, AdsErrorKind.Controlled);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
|
||||
}
|
||||
catch (Microsoft.Data.SqlClient.SqlException ex)
|
||||
{
|
||||
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Sql);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Unhandled);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Handle(HttpContext ctx, HttpStatusCode code, Exception ex, AdsErrorKind kind)
|
||||
{
|
||||
await _logger.LogAsync(new AdsErrorEvent
|
||||
{
|
||||
Kind = kind,
|
||||
Path = ctx.Request.Path.ToString(),
|
||||
Method = ctx.Request.Method,
|
||||
TraceId = ctx.TraceIdentifier,
|
||||
Message = ex.Message,
|
||||
Exception = ex.ToString()
|
||||
});
|
||||
|
||||
ctx.Response.StatusCode = (int)code;
|
||||
ctx.Response.ContentType = "application/json";
|
||||
|
||||
var payload = new ApiResponse<object>
|
||||
{
|
||||
StatusCode = (int)code,
|
||||
StatusCodeText = code.ToString(),
|
||||
ConsoleMessage = ex.Message,
|
||||
ScreenMessage = new UiMessage
|
||||
{
|
||||
MessageType = 2, // ejemplo: 1=info,2=error,3=warn
|
||||
Message = "Ocurrió un error al procesar la solicitud.",
|
||||
MessageCode = 0
|
||||
},
|
||||
Value = null
|
||||
};
|
||||
|
||||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ADS.Helpers.Middleware.ADSException;
|
||||
|
||||
public sealed class FileAdsErrorLogger : IAdsErrorLogger
|
||||
{
|
||||
private readonly string _folder;
|
||||
|
||||
public FileAdsErrorLogger(IOptions<LoggingOptions> options)
|
||||
{
|
||||
_folder = options.Value.LogFolder ?? "logs";
|
||||
Directory.CreateDirectory(_folder);
|
||||
}
|
||||
|
||||
public Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
var path = Path.Combine(_folder, $"ads-errors-{day}.jsonl");
|
||||
|
||||
var line = JsonSerializer.Serialize(evt);
|
||||
return File.AppendAllTextAsync(path, line + Environment.NewLine, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ADS.Helpers.Middleware.ADSException;
|
||||
|
||||
public enum AdsErrorKind
|
||||
{
|
||||
Sql = 1,
|
||||
Controlled = 2,
|
||||
Unhandled = 3
|
||||
}
|
||||
|
||||
public sealed class AdsErrorEvent
|
||||
{
|
||||
public AdsErrorKind Kind { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? Method { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? Exception { get; init; }
|
||||
public DateTime Utc { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public interface IAdsErrorLogger
|
||||
{
|
||||
Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Middleware.ADSException;
|
||||
|
||||
public sealed class LoggingOptions
|
||||
{
|
||||
public string LogFolder { get; set; } = "logs";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using ADS.Helpers.Middleware.ADSException;
|
||||
using ADS.Helpers.Middleware.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ADS.Helpers.Middleware.ADSExtensions;
|
||||
|
||||
public static class AdsApiResponseExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsApiResponse(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Leer configuración desde appsettings de la API
|
||||
services.Configure<LoggingOptions>(
|
||||
configuration.GetSection("LoggingOptions"));
|
||||
|
||||
services.AddSingleton<IAdsErrorLogger, FileAdsErrorLogger>();
|
||||
|
||||
services.AddControllers(o =>
|
||||
{
|
||||
o.Filters.Add<ApiResponseWrapFilter>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseAdsApiResponse(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseMiddleware<AdsExceptionMiddleware>();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
76
Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs
Normal file
76
Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using ADS.Helpers.Middleware.Tenant;
|
||||
using ADS.Helpers.Middleware.Logging;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ADS.Helpers.MultiTenant.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Resolvers;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using ADS.Helpers.Base.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Configuration;
|
||||
using ADS.Helpers.MultiTenant.Stores;
|
||||
using ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
namespace ADS.Helpers.Middleware.Extensions;
|
||||
|
||||
public static class AdsExtensions
|
||||
{
|
||||
// IServiceCollection extensions
|
||||
public static IServiceCollection AddAdsLogging(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
//services.AddLogging();
|
||||
|
||||
// Ejemplo si tenías esto:
|
||||
services.AddSingleton<IAppLogger, DefaultAppLogger>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdsMultiTenant(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Aquí va lo que ya tengas para tenant: options, accessor, resolver, etc.
|
||||
services.Configure<TenantOptions>(configuration.GetSection("MultiTenant"));
|
||||
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||
// services.AddSingleton<ITenantStore, InMemoryTenantStore>();// reemplazado por ConfigurationTenantStore
|
||||
services.AddSingleton<ITenantStore, ConfigurationTenantStore>();
|
||||
services.AddScoped<ITenantResolver, HeaderTenantResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdsMiddlewares(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Si este método es solo un "orquestador" de otros registros:
|
||||
services.AddAdsLogging(configuration);
|
||||
services.AddAdsMultiTenant(configuration);
|
||||
|
||||
// Y aquí agregas otros (ExceptionHandling, CorrelationId, etc.)
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// IApplicationBuilder extensions
|
||||
public static IApplicationBuilder UseAdsTenant(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<TenantMiddleware>();
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseAdsMiddlewares(this IApplicationBuilder app)
|
||||
{
|
||||
// Orden típico: errores/correlation/tenant/etc (según tu diseño)
|
||||
app.UseAdsTenant();
|
||||
return app;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdsInternalUser(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IInternalUserContextAccessor, InternalUserContextAccessor>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseAdsInternalUser(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<InternalUserContextMiddleware>();
|
||||
}
|
||||
|
||||
}
|
||||
20
Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs
Normal file
20
Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace ADS.Helpers.Middleware.Http;
|
||||
|
||||
public sealed class ApiResponse<T>
|
||||
{
|
||||
public int StatusCode { get; init; }
|
||||
public int TotalRows { get; init; }
|
||||
public string StatusCodeText { get; init; } = string.Empty;
|
||||
|
||||
public string? ConsoleMessage { get; init; }
|
||||
public UiMessage? ScreenMessage { get; init; }
|
||||
|
||||
public T? Value { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UiMessage
|
||||
{
|
||||
public int MessageType { get; init; }
|
||||
public string Message { get; init; } = "";
|
||||
public int MessageCode { get; init; }
|
||||
}
|
||||
62
Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs
Normal file
62
Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ADS.Helpers.Middleware.Http;
|
||||
|
||||
public sealed class ApiResponseWrapFilter : IAsyncResultFilter
|
||||
{
|
||||
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
|
||||
{
|
||||
// Si es FileResult / Stream / etc., no lo toques
|
||||
if (context.Result is FileResult)
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Result is ObjectResult obj)
|
||||
{
|
||||
// Si ya viene envuelto, no reenvolver
|
||||
if (obj.Value is not null)
|
||||
{
|
||||
var t = obj.Value.GetType();
|
||||
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ApiResponse<>))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var code = obj.StatusCode ?? 200;
|
||||
context.Result = new ObjectResult(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = code,
|
||||
StatusCodeText = ReasonPhrase(code),
|
||||
Value = obj.Value
|
||||
})
|
||||
{ StatusCode = code };
|
||||
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si no es ObjectResult (por ejemplo EmptyResult), déjalo pasar
|
||||
await next();
|
||||
}
|
||||
|
||||
private static string ReasonPhrase(int code) => code switch
|
||||
{
|
||||
200 => "OK",
|
||||
201 => "Created",
|
||||
202 => "Accepted",
|
||||
204 => "NoContent",
|
||||
400 => "BadRequest",
|
||||
401 => "Unauthorized",
|
||||
403 => "Forbidden",
|
||||
404 => "NotFound",
|
||||
409 => "Conflict",
|
||||
422 => "UnprocessableEntity",
|
||||
500 => "ServerError",
|
||||
_ => "Status"
|
||||
};
|
||||
}
|
||||
9
Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs
Normal file
9
Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ADS.Helpers.Middleware.Http;
|
||||
|
||||
public sealed class PagedQuery
|
||||
{
|
||||
public string? Search { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 50;
|
||||
public bool Pagination { get; init; } = true;
|
||||
}
|
||||
11
Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs
Normal file
11
Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ADS.Helpers.Middleware.Http;
|
||||
|
||||
public sealed class RequestOf<T>
|
||||
{
|
||||
public T? Value { get; init; }
|
||||
|
||||
public string? Search { get; init; }
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 50;
|
||||
public bool Pagination { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
public interface IInternalUserContextAccessor
|
||||
{
|
||||
InternalUserContext? Current { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
public class InternalAuthorizeAttribute : Attribute, IAuthorizationFilter
|
||||
{
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
var allowAnonymous = context.ActionDescriptor.EndpointMetadata
|
||||
.OfType<AllowAnonymousAttribute>()
|
||||
.Any();
|
||||
|
||||
if (allowAnonymous)
|
||||
return;
|
||||
|
||||
var accessor = context.HttpContext.RequestServices
|
||||
.GetService(typeof(IInternalUserContextAccessor)) as IInternalUserContextAccessor;
|
||||
|
||||
if (accessor?.Current == null)
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
public class InternalUserContext
|
||||
{
|
||||
public int CveUser { get; set; }
|
||||
public int CveEmpresa { get; set; }
|
||||
public int CveCliente { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
public class InternalUserContextAccessor : IInternalUserContextAccessor
|
||||
{
|
||||
public InternalUserContext? Current { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ADS.Helpers.Middleware.InternalUser;
|
||||
|
||||
public class InternalUserContextMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public InternalUserContextMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
IInternalUserContextAccessor accessor,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var cveUserHeader = context.Request.Headers[InternalHeaders.CveUser].FirstOrDefault();
|
||||
var cveEmpresaHeader = context.Request.Headers[InternalHeaders.CveEmpresa].FirstOrDefault();
|
||||
var cveClienteHeader = context.Request.Headers[InternalHeaders.CveCliente].FirstOrDefault();
|
||||
var timestampHeader = context.Request.Headers[InternalHeaders.Timestamp].FirstOrDefault();
|
||||
var signatureHeader = context.Request.Headers[InternalHeaders.User_Signature].FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cveUserHeader) &&
|
||||
!string.IsNullOrWhiteSpace(cveEmpresaHeader) &&
|
||||
!string.IsNullOrWhiteSpace(cveClienteHeader) &&
|
||||
!string.IsNullOrWhiteSpace(timestampHeader) &&
|
||||
!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
var secret = configuration["InternalRouting:SharedSecret"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
throw new InvalidOperationException("InternalRouting:SharedSecret no está configurado.");
|
||||
|
||||
var raw = $"{cveUserHeader}|{cveEmpresaHeader}|{cveClienteHeader}|{timestampHeader}";
|
||||
var expectedSignature = ComputeHmacSha256(raw, secret);
|
||||
|
||||
if (signatureHeader == expectedSignature)
|
||||
{
|
||||
accessor.Current = new InternalUserContext
|
||||
{
|
||||
CveUser = int.Parse(cveUserHeader),
|
||||
CveEmpresa = int.Parse(cveEmpresaHeader),
|
||||
CveCliente = int.Parse(cveClienteHeader)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string ComputeHmacSha256(string value, string secret)
|
||||
{
|
||||
var key = Encoding.UTF8.GetBytes(secret);
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
|
||||
using var hmac = new HMACSHA256(key);
|
||||
var hash = hmac.ComputeHash(bytes);
|
||||
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
24
Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs
Normal file
24
Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using ADS.Helpers.Base.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ADS.Helpers.Middleware.Logging;
|
||||
|
||||
public sealed class DefaultAppLogger : IAppLogger
|
||||
{
|
||||
private readonly ILogger<DefaultAppLogger> _logger;
|
||||
|
||||
public DefaultAppLogger(ILogger<DefaultAppLogger> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Info(string source, string code, string message, object? data = null)
|
||||
=> _logger.LogInformation("{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||
|
||||
public void Warn(string source, string code, string message, object? data = null)
|
||||
=> _logger.LogWarning("{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||
|
||||
public void Error(string source, string code, string message, Exception? ex = null, object? data = null)
|
||||
=> _logger.LogError(ex, "{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||
}
|
||||
57
Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs
Normal file
57
Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.Base.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ADS.Helpers.Middleware.Tenant;
|
||||
|
||||
public sealed class TenantMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public TenantMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
// Scoped dependencies se resuelven aquí (por request)
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ITenantResolver tenantResolver,
|
||||
ITenantStore tenantStore,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IAppLogger log)
|
||||
{
|
||||
var tenantKey = tenantResolver.Resolve(context);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantKey))
|
||||
{
|
||||
log.Warn("ADS.MultiTenant", "TENANT_NOT_RESOLVED", "Tenant key could not be resolved.");
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync("Tenant not resolved.");
|
||||
return;
|
||||
}
|
||||
|
||||
var tenant = await tenantStore.GetAsync(tenantKey);
|
||||
|
||||
if (tenant is null)
|
||||
{
|
||||
log.Warn("ADS.MultiTenant", "TENANT_NOT_FOUND", $"Tenant '{tenantKey}' not found.");
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsync("Tenant not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
tenantContextAccessor.SetCurrent(tenant);
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.SetCurrent(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Abstractions;
|
||||
|
||||
public interface ITenantResolver
|
||||
{
|
||||
string? Resolve(HttpContext context);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Abstractions;
|
||||
|
||||
public interface ITenantStore
|
||||
{
|
||||
Task<TenantContext?> GetAsync(string tenantKey);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ADS.Helpers.MultiTenant.Configuration;
|
||||
|
||||
public sealed class TenantOptions
|
||||
{
|
||||
public string HeaderName { get; set; } = "X-Tenant";
|
||||
public string? DefaultTenantId { get; set; }="demo";
|
||||
public bool RequireTenant { get; set; } = false;
|
||||
|
||||
// Para no romper swagger
|
||||
public string[] BypassPaths { get; set; } = ["/swagger"];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ADS.Helpers.MultiTenant.Context;
|
||||
|
||||
public interface ITenantContextAccessor
|
||||
{
|
||||
TenantContext? Current { get; }
|
||||
void SetCurrent(TenantContext? context);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ADS.Helpers.MultiTenant.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Stores;
|
||||
|
||||
public sealed class InMemoryTenantStore : ITenantStore
|
||||
{
|
||||
private readonly Dictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["demo"] = new TenantContext { TenantId = "demo", EmpresaId = 172, ClienteId = 1 }
|
||||
};
|
||||
public Task<TenantContext?> GetAsync(string tenantKey)
|
||||
{
|
||||
_tenants.TryGetValue(tenantKey, out var tenant);
|
||||
return Task.FromResult<TenantContext?>(tenant);
|
||||
}
|
||||
}
|
||||
22
Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs
Normal file
22
Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace ADS.Helpers.MultiTenant.Context;
|
||||
|
||||
public sealed class TenantContext
|
||||
{
|
||||
public int EmpresaId { get; set; }
|
||||
public int ClienteId { get; set; }
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
public string Culture { get; set; } = "es-MX";
|
||||
public string Theme { get; set; } = string.Empty;
|
||||
|
||||
public DatabaseConfig Database { get; set; } = new();
|
||||
|
||||
// Settings no tipados (solo si quieres algo rápido para cosas raras)
|
||||
public Dictionary<string, string> CustomSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DatabaseConfig
|
||||
{
|
||||
public string Provider { get; set; } = "SqlServer"; // SqlServer | Postgres
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Context;
|
||||
|
||||
public sealed class TenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TenantContext?> _current = new();
|
||||
|
||||
public TenantContext? Current => _current.Value;
|
||||
|
||||
public void SetCurrent(TenantContext? context) => _current.Value = context;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Data;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Data;
|
||||
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
IDbConnection Create();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Data;
|
||||
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Data;
|
||||
|
||||
public sealed class TenantDbConnectionFactory : IDbConnectionFactory
|
||||
{
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public TenantDbConnectionFactory(IHttpContextAccessor http) => _http = http;
|
||||
|
||||
public IDbConnection Create()
|
||||
{
|
||||
var ctx = _http.HttpContext ?? throw new InvalidOperationException("HTTP_CONTEXT_NOT_AVAILABLE");
|
||||
|
||||
if (!ctx.Items.TryGetValue(InternalTenantDbHeaders.ItemConn, out var csObj) ||
|
||||
csObj is not string cs ||
|
||||
string.IsNullOrWhiteSpace(cs))
|
||||
throw new InvalidOperationException("INTERNAL_CONN_MISSING");
|
||||
|
||||
return new SqlConnection(cs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
// MICRO: valida headers internos antes de controllers
|
||||
public static IApplicationBuilder UseInternalTenantDb(this IApplicationBuilder app)
|
||||
=> app.UseMiddleware<InternalTenantDbMiddleware>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public static class InternalHeaders
|
||||
{
|
||||
public const string Tenant = "X-Internal-Tenant";
|
||||
public const string Conn = "X-Internal-Conn";
|
||||
public const string Timestamp = "X-Internal-Ts";
|
||||
public const string Signature = "X-Internal-Sig";
|
||||
// NUEVOS (lo que faltaba para no “inventar” en GPS)
|
||||
public const string EmpresaId = "X-Internal-EmpresaId";
|
||||
public const string ClienteId = "X-Internal-ClienteId";
|
||||
|
||||
// Opcionales
|
||||
public const string Culture = "X-Internal-Culture";
|
||||
public const string Theme = "X-Internal-Theme";
|
||||
public const string DbProvider= "X-Internal-DbProvider";
|
||||
|
||||
public const string CveUser = "X-Internal-CveUser";
|
||||
public const string CveEmpresa = "X-Internal-CveEmpresa";
|
||||
public const string CveCliente = "X-Internal-CveCliente";
|
||||
public const string User_Timestamp = "X-Internal-User-Timestamp";
|
||||
public const string User_Signature = "X-Internal-User-Signature";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public sealed class InternalRoutingOptions
|
||||
{
|
||||
public string SharedSecret { get; set; } = default!;
|
||||
public int MaxSkewSeconds { get; set; } = 120;
|
||||
|
||||
public string HeaderTenant { get; set; } = InternalHeaders.Tenant;
|
||||
public string HeaderConn { get; set; } = InternalHeaders.Conn;
|
||||
public string HeaderTs { get; set; } = InternalHeaders.Timestamp;
|
||||
public string HeaderSig { get; set; } = InternalHeaders.Signature;
|
||||
|
||||
// NUEVOS
|
||||
public string HeaderEmpresaId { get; set; } = InternalHeaders.EmpresaId;
|
||||
public string HeaderClienteId { get; set; } = InternalHeaders.ClienteId;
|
||||
|
||||
// Opcionales
|
||||
public string HeaderCulture { get; set; } = InternalHeaders.Culture;
|
||||
public string HeaderTheme { get; set; } = InternalHeaders.Theme;
|
||||
public string HeaderDbProvider{ get; set; } = InternalHeaders.DbProvider;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public static class InternalSignature
|
||||
{
|
||||
public static string Compute(
|
||||
string secret,
|
||||
string tenantId,
|
||||
string conn,
|
||||
int empresaId,
|
||||
int clienteId,
|
||||
string provider,
|
||||
string culture,
|
||||
string theme,
|
||||
long tsUnix)
|
||||
{
|
||||
var payload = $"{tenantId}\n{conn}\n{empresaId}\n{clienteId}\n{provider}\n{culture}\n{theme}\n{tsUnix}";
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexString(hash); // HEX uppercase
|
||||
}
|
||||
|
||||
// Backward compatible (por si algún host viejo sigue firmando con el formato anterior)
|
||||
public static string Compute(string secret, string tenantId, string conn, long tsUnix)
|
||||
=> Compute(secret, tenantId, conn, 0, 0, "SqlServer", "es-MX", "", tsUnix);
|
||||
|
||||
public static bool FixedTimeEqualsHex(string expectedHex, string providedHex)
|
||||
{
|
||||
if (expectedHex.Length != providedHex.Length) return false;
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.ASCII.GetBytes(expectedHex),
|
||||
Encoding.ASCII.GetBytes(providedHex));
|
||||
}
|
||||
|
||||
public static bool IsTimestampValid(long tsUnix, int maxSkewSeconds)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var diff = Math.Abs(now - tsUnix);
|
||||
return diff <= maxSkewSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public interface IInternalTenantDbHeaderWriter
|
||||
{
|
||||
long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant);
|
||||
}
|
||||
|
||||
public sealed class InternalTenantDbHeaderWriter : IInternalTenantDbHeaderWriter
|
||||
{
|
||||
private readonly InternalRoutingOptions _opt;
|
||||
|
||||
public InternalTenantDbHeaderWriter(IOptions<InternalRoutingOptions> opt) => _opt = opt.Value;
|
||||
|
||||
public long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant)
|
||||
{
|
||||
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
var tenantId = tenant.TenantId ?? "";
|
||||
var conn = tenant.Database?.ConnectionString ?? "";
|
||||
var empresaId = tenant.EmpresaId;
|
||||
var clienteId = tenant.ClienteId;
|
||||
var provider = tenant.Database?.Provider ?? "SqlServer";
|
||||
var culture = tenant.Culture ?? "es-MX";
|
||||
var theme = tenant.Theme ?? "";
|
||||
|
||||
var sig = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
|
||||
|
||||
headers[_opt.HeaderTenant] = tenantId;
|
||||
headers[_opt.HeaderConn] = conn;
|
||||
|
||||
headers[_opt.HeaderEmpresaId] = empresaId.ToString();
|
||||
headers[_opt.HeaderClienteId] = clienteId.ToString();
|
||||
|
||||
headers[_opt.HeaderDbProvider] = provider;
|
||||
headers[_opt.HeaderCulture] = culture;
|
||||
headers[_opt.HeaderTheme] = theme;
|
||||
|
||||
headers[_opt.HeaderTs] = ts.ToString();
|
||||
headers[_opt.HeaderSig] = sig;
|
||||
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public static class InternalTenantDbHeaders
|
||||
{
|
||||
public const string ItemTenantId = "Internal.TenantId";
|
||||
public const string ItemConn = "Internal.Conn";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public sealed class InternalTenantDbMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly InternalRoutingOptions _opt;
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
|
||||
public InternalTenantDbMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<InternalRoutingOptions> opt,
|
||||
ITenantContextAccessor tenantAccessor)
|
||||
{
|
||||
_next = next;
|
||||
_opt = opt.Value;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
if (!TryRead(ctx,
|
||||
out var tenantId,
|
||||
out var conn,
|
||||
out var empresaId,
|
||||
out var clienteId,
|
||||
out var provider,
|
||||
out var culture,
|
||||
out var theme,
|
||||
out var ts,
|
||||
out var sig))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!InternalSignature.IsTimestampValid(ts, _opt.MaxSkewSeconds))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
// Firma NUEVA (con empresa/cliente/etc)
|
||||
var expected = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
|
||||
|
||||
// Backward compatible: si el Host todavía firma “viejo”, cae al compute viejo
|
||||
if (!InternalSignature.FixedTimeEqualsHex(expected, sig))
|
||||
{
|
||||
var expectedOld = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, ts);
|
||||
if (!InternalSignature.FixedTimeEqualsHex(expectedOld, sig))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// backward compatible items
|
||||
ctx.Items[InternalTenantDbHeaders.ItemTenantId] = tenantId;
|
||||
ctx.Items[InternalTenantDbHeaders.ItemConn] = conn;
|
||||
|
||||
_tenantAccessor.SetCurrent(new TenantContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
EmpresaId = empresaId,
|
||||
ClienteId = clienteId,
|
||||
Culture = culture,
|
||||
Theme = theme,
|
||||
Database = new DatabaseConfig
|
||||
{
|
||||
Provider = provider,
|
||||
ConnectionString = conn
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _next(ctx);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tenantAccessor.SetCurrent(null);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRead(
|
||||
HttpContext ctx,
|
||||
out string tenantId,
|
||||
out string conn,
|
||||
out int empresaId,
|
||||
out int clienteId,
|
||||
out string provider,
|
||||
out string culture,
|
||||
out string theme,
|
||||
out long ts,
|
||||
out string sig)
|
||||
{
|
||||
tenantId = conn = sig = "";
|
||||
empresaId = 0;
|
||||
clienteId = 0;
|
||||
provider = "SqlServer";
|
||||
culture = "es-MX";
|
||||
theme = "";
|
||||
ts = 0;
|
||||
|
||||
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTenant, out var t)) return false;
|
||||
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderConn, out var c)) return false;
|
||||
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTs, out var tsStr)) return false;
|
||||
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderSig, out var s)) return false;
|
||||
|
||||
tenantId = t.ToString();
|
||||
conn = c.ToString();
|
||||
sig = s.ToString();
|
||||
|
||||
// NUEVOS obligatorios para Sicrep (si no vienen, dejamos 0 y el SP puede trabajar con -1)
|
||||
if (ctx.Request.Headers.TryGetValue(_opt.HeaderEmpresaId, out var eStr))
|
||||
int.TryParse(eStr.ToString(), out empresaId);
|
||||
|
||||
if (ctx.Request.Headers.TryGetValue(_opt.HeaderClienteId, out var clStr))
|
||||
int.TryParse(clStr.ToString(), out clienteId);
|
||||
|
||||
// Opcionales
|
||||
if (ctx.Request.Headers.TryGetValue(_opt.HeaderDbProvider, out var p)) provider = p.ToString();
|
||||
if (ctx.Request.Headers.TryGetValue(_opt.HeaderCulture, out var cul)) culture = cul.ToString();
|
||||
if (ctx.Request.Headers.TryGetValue(_opt.HeaderTheme, out var th)) theme = th.ToString();
|
||||
|
||||
return !string.IsNullOrWhiteSpace(tenantId)
|
||||
&& !string.IsNullOrWhiteSpace(conn)
|
||||
&& long.TryParse(tsStr.ToString(), out ts)
|
||||
&& !string.IsNullOrWhiteSpace(sig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using ADS.Helpers.MultiTenant.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.InternalRouting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
// MICRO: registra middleware options + HttpContextAccessor + DB factory
|
||||
public static IServiceCollection AddInternalTenantDb(this IServiceCollection services, IConfiguration cfg)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
|
||||
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
|
||||
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||
|
||||
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
|
||||
return services;
|
||||
}
|
||||
|
||||
// HOST: registra solo el writer (para agregar headers internos)
|
||||
public static IServiceCollection AddInternalTenantDbHeaderWriter(this IServiceCollection services, IConfiguration cfg)
|
||||
{
|
||||
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
|
||||
services.AddSingleton<IInternalTenantDbHeaderWriter, InternalTenantDbHeaderWriter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ADS.Helpers.MultiTenant.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Resolvers;
|
||||
|
||||
public sealed class HeaderTenantResolver : ITenantResolver
|
||||
{
|
||||
private readonly TenantOptions _opt;
|
||||
|
||||
public HeaderTenantResolver(IOptions<TenantOptions> opt) => _opt = opt.Value;
|
||||
|
||||
public string? Resolve(HttpContext context)
|
||||
{
|
||||
|
||||
if (context.Request.Headers.TryGetValue(_opt.HeaderName, out var value))
|
||||
return value.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_opt.DefaultTenantId))
|
||||
return _opt.DefaultTenantId;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ADS.Helpers.MultiTenant.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ADS.Helpers.MultiTenant.Stores;
|
||||
|
||||
public sealed class ConfigurationTenantStore : ITenantStore
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public ConfigurationTenantStore(IConfiguration cfg) => _cfg = cfg;
|
||||
|
||||
public Task<TenantContext?> GetAsync(string tenantKey)
|
||||
{
|
||||
var section = _cfg.GetSection($"Tenants:{tenantKey}");
|
||||
if (!section.Exists()) return Task.FromResult<TenantContext?>(null);
|
||||
|
||||
var tenant = section.Get<TenantContext>() ?? new TenantContext();
|
||||
tenant.TenantId = tenantKey; // importante
|
||||
return Task.FromResult<TenantContext?>(tenant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||
<ProjectReference Include="..\ADS.Helpers.Localization\ADS.Helpers.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.15.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Abstractions;
|
||||
|
||||
public interface INotificationProvider
|
||||
{
|
||||
NotificationChannel Channel { get; }
|
||||
string Name { get; }
|
||||
|
||||
Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Abstractions;
|
||||
|
||||
public interface INotificationSettingsProvider
|
||||
{
|
||||
Task<TSettings> GetAsync<TSettings>(string tenantId, CancellationToken ct = default)
|
||||
where TSettings : class, new();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Abstractions;
|
||||
|
||||
public interface INotifyCenter
|
||||
{
|
||||
Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using ADS.Helpers.NotifyCenter.Abstractions;
|
||||
using ADS.Helpers.NotifyCenter.Models;
|
||||
using ADS.Helpers.NotifyCenter.Providers;
|
||||
using ADS.Helpers.NotifyCenter.Providers.Smtp;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsNotifyCenter(this IServiceCollection services)
|
||||
{
|
||||
services.AddOptions<NotifyCenterOptions>();
|
||||
services.AddSingleton<INotifyCenter, NotifyCenter>();
|
||||
|
||||
// Default settings provider (replace later with tenant-aware provider)
|
||||
services.AddSingleton<InMemoryNotificationSettingsProvider>();
|
||||
services.AddSingleton<INotificationSettingsProvider>(sp => sp.GetRequiredService<InMemoryNotificationSettingsProvider>());
|
||||
|
||||
// Providers
|
||||
services.AddSingleton<INotificationProvider, SmtpNotificationProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Helper to configure SMTP quickly when using InMemoryNotificationSettingsProvider
|
||||
public static IServiceCollection ConfigureAdsSmtp(this IServiceCollection services, SmtpOptions options, string? tenantId = null)
|
||||
{
|
||||
var sp = services.BuildServiceProvider();
|
||||
var mem = sp.GetRequiredService<InMemoryNotificationSettingsProvider>();
|
||||
mem.Set(options, tenantId);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public static class MessageSources
|
||||
{
|
||||
public const string NotifyCenter = "ADS.NotifyCenter";
|
||||
public const string SmtpProvider = "ADS.NotifyCenter.Smtp";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public static class NotifyCodes
|
||||
{
|
||||
public const string Ok = "NOTIFY_OK";
|
||||
public const string NoProvider = "NOTIFY_NO_PROVIDER";
|
||||
public const string InvalidMessage = "NOTIFY_INVALID_MESSAGE";
|
||||
|
||||
public const string SmtpNotConfigured = "SMTP_NOT_CONFIGURED";
|
||||
public const string SmtpSendFailed = "SMTP_SEND_FAILED";
|
||||
public const string TenantContextMissing = "TENANT_CONTEXT_MISSING";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public enum NotificationChannel
|
||||
{
|
||||
SmtpEmail = 1,
|
||||
SignalR = 2,
|
||||
Sms = 3,
|
||||
WhatsApp = 4,
|
||||
Webhook = 5,
|
||||
Push = 6
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public sealed class NotificationMessage
|
||||
{
|
||||
public required NotificationChannel Channel { get; init; }
|
||||
|
||||
// Generic recipients. Providers can interpret these as they need.
|
||||
// For SMTP: To/Cc/Bcc are emails.
|
||||
public List<string> To { get; init; } = new();
|
||||
public List<string> Cc { get; init; } = new();
|
||||
public List<string> Bcc { get; init; } = new();
|
||||
|
||||
public string? Subject { get; init; }
|
||||
public string? BodyText { get; init; }
|
||||
public string? BodyHtml { get; init; }
|
||||
|
||||
// Optional provider-specific metadata (e.g. phone numbers formatting, template ids, etc.)
|
||||
public Dictionary<string, string> Metadata { get; init; } = new();
|
||||
|
||||
// Optional correlation id from caller
|
||||
public string? TraceId { get; init; }
|
||||
public string? Culture { get; init; }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public sealed class NotificationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Source { get; init; } = ""; // quien emitió
|
||||
public string Code { get; init; } = ""; // keyword estable
|
||||
|
||||
public string? Message { get; init; } // ya traducido (si se resolvió)
|
||||
public string? DebugMessage { get; init; }
|
||||
|
||||
public string? ProviderName { get; init; }
|
||||
public string? ProviderMessageId { get; init; }
|
||||
|
||||
public static NotificationResult Ok(
|
||||
string providerName,
|
||||
string source,
|
||||
string code,
|
||||
string? providerMessageId = null,
|
||||
string? debugMessage = null)
|
||||
=> new()
|
||||
{
|
||||
Success = true,
|
||||
ProviderName = providerName,
|
||||
Source = source,
|
||||
Code = code,
|
||||
ProviderMessageId = providerMessageId,
|
||||
DebugMessage = debugMessage
|
||||
};
|
||||
|
||||
public static NotificationResult Fail(
|
||||
string providerName,
|
||||
string source,
|
||||
string code,
|
||||
string? debugMessage = null)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
ProviderName = providerName,
|
||||
Source = source,
|
||||
Code = code,
|
||||
DebugMessage = debugMessage
|
||||
};
|
||||
|
||||
public NotificationResult WithMessage(string message)
|
||||
=> new()
|
||||
{
|
||||
Success = Success,
|
||||
ProviderName = ProviderName,
|
||||
ProviderMessageId = ProviderMessageId,
|
||||
Source = Source,
|
||||
Code = Code,
|
||||
DebugMessage = DebugMessage,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Models;
|
||||
|
||||
public sealed class NotifyCenterOptions
|
||||
{
|
||||
// If true, NotifyCenter will throw when no provider is registered for the requested channel.
|
||||
public bool ThrowIfNoProvider { get; set; } = true;
|
||||
|
||||
// If true, NotifyCenter will validate basic fields and fail fast.
|
||||
public bool ValidateMessage { get; set; } = true;
|
||||
}
|
||||
179
Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs
Normal file
179
Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.Base.Abstractions;
|
||||
using ADS.Helpers.Localization.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using ADS.Helpers.NotifyCenter.Abstractions;
|
||||
using ADS.Helpers.NotifyCenter.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter;
|
||||
|
||||
public sealed class NotifyCenter : INotifyCenter
|
||||
{
|
||||
private readonly IReadOnlyList<INotificationProvider> _providers;
|
||||
private readonly NotifyCenterOptions _options;
|
||||
private readonly IMessageCatalog _catalog;
|
||||
private readonly IAppLogger _log;
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
|
||||
private const string DefaultCulture = "es-MX";
|
||||
|
||||
public NotifyCenter(
|
||||
IEnumerable<INotificationProvider> providers,
|
||||
IOptions<NotifyCenterOptions> options,
|
||||
IMessageCatalog catalog,
|
||||
IAppLogger log,
|
||||
ITenantContextAccessor tenantAccessor)
|
||||
{
|
||||
_providers = providers.ToList();
|
||||
_options = options.Value;
|
||||
_catalog = catalog;
|
||||
_log = log;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default)
|
||||
{
|
||||
var tenant = _tenantAccessor.Current;
|
||||
|
||||
if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
|
||||
{
|
||||
var dbg = "TenantContext is not available. Ensure TenantMiddleware runs before using NotifyCenter.";
|
||||
|
||||
_log.Error(
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.TenantContextMissing,
|
||||
message: dbg,
|
||||
ex: null,
|
||||
data: new { message.TraceId });
|
||||
|
||||
var fail = NotificationResult.Fail(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.TenantContextMissing,
|
||||
debugMessage: dbg);
|
||||
|
||||
return Localize(fail, DefaultCulture);
|
||||
}
|
||||
|
||||
var culture = string.IsNullOrWhiteSpace(tenant.Culture) ? DefaultCulture : tenant.Culture;
|
||||
|
||||
if (_options.ValidateMessage)
|
||||
{
|
||||
var validation = Validate(message);
|
||||
if (!validation.Success)
|
||||
return Localize(validation, culture);
|
||||
}
|
||||
|
||||
var provider = _providers.FirstOrDefault(p => p.Channel == message.Channel);
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
var debug = $"No notification provider registered for channel '{message.Channel}'.";
|
||||
|
||||
if (_options.ThrowIfNoProvider)
|
||||
{
|
||||
_log.Error(
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.NoProvider,
|
||||
message: debug,
|
||||
ex: null,
|
||||
data: new { message.TraceId, tenant.TenantId });
|
||||
|
||||
throw new InvalidOperationException(debug);
|
||||
}
|
||||
|
||||
_log.Warn(
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.NoProvider,
|
||||
message: debug,
|
||||
data: new { message.TraceId, tenant.TenantId });
|
||||
|
||||
var fail = NotificationResult.Fail(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.NoProvider,
|
||||
debugMessage: debug);
|
||||
|
||||
return Localize(fail, culture);
|
||||
}
|
||||
|
||||
// Important: providers can still use tenant via TenantContextAccessor if needed,
|
||||
// or you can pass tenant id through message.Metadata.
|
||||
// Here we pass it so providers that are settings-driven can resolve settings by tenant.
|
||||
message.Metadata["tenantId"] = tenant.TenantId;
|
||||
|
||||
var result = await provider.SendAsync(message, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_log.Error(
|
||||
source: result.Source,
|
||||
code: result.Code,
|
||||
message: "Notification provider failed.",
|
||||
ex: null,
|
||||
data: new
|
||||
{
|
||||
message.TraceId,
|
||||
tenant.TenantId,
|
||||
result.ProviderName,
|
||||
result.DebugMessage
|
||||
});
|
||||
}
|
||||
|
||||
return Localize(result, culture);
|
||||
}
|
||||
|
||||
private NotificationResult Localize(NotificationResult result, string culture)
|
||||
{
|
||||
var localizedMessage = _catalog.Get(
|
||||
source: result.Source,
|
||||
code: result.Code,
|
||||
culture: culture);
|
||||
|
||||
return result.WithMessage(localizedMessage);
|
||||
}
|
||||
|
||||
private static NotificationResult Validate(NotificationMessage message)
|
||||
{
|
||||
if (message.To.Count == 0 && message.Cc.Count == 0 && message.Bcc.Count == 0)
|
||||
{
|
||||
return NotificationResult.Fail(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.InvalidMessage,
|
||||
debugMessage: "To/Cc/Bcc are all empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message.Subject) &&
|
||||
message.Channel == NotificationChannel.SmtpEmail)
|
||||
{
|
||||
return NotificationResult.Fail(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.InvalidMessage,
|
||||
debugMessage: "Subject is required for SMTP.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message.BodyText) &&
|
||||
string.IsNullOrWhiteSpace(message.BodyHtml))
|
||||
{
|
||||
return NotificationResult.Fail(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.InvalidMessage,
|
||||
debugMessage: "BodyText and BodyHtml are empty.");
|
||||
}
|
||||
|
||||
return NotificationResult.Ok(
|
||||
providerName: "NotifyCenter",
|
||||
source: MessageSources.NotifyCenter,
|
||||
code: NotifyCodes.Ok,
|
||||
providerMessageId: null,
|
||||
debugMessage: "Validation passed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.NotifyCenter.Abstractions;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Providers;
|
||||
|
||||
// Simple in-memory settings store. Later you replace this with a tenant-aware provider (DB/SQLite/etc.)
|
||||
public sealed class InMemoryNotificationSettingsProvider : INotificationSettingsProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Type type, string? tenantId), object> _store = new();
|
||||
|
||||
public Task<TSettings> GetAsync<TSettings>(string? tenantId, CancellationToken ct = default)
|
||||
where TSettings : class, new()
|
||||
{
|
||||
var key = (typeof(TSettings), tenantId);
|
||||
if (_store.TryGetValue(key, out var obj) && obj is TSettings typed)
|
||||
return Task.FromResult(typed);
|
||||
|
||||
// fallback to default (tenantId = null)
|
||||
var keyDefault = (typeof(TSettings), (string?)null);
|
||||
if (_store.TryGetValue(keyDefault, out var objDefault) && objDefault is TSettings typedDefault)
|
||||
return Task.FromResult(typedDefault);
|
||||
|
||||
return Task.FromResult(new TSettings());
|
||||
}
|
||||
|
||||
public void Set<TSettings>(TSettings settings, string? tenantId = null) where TSettings : class
|
||||
{
|
||||
_store[(typeof(TSettings), tenantId)] = settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ADS.Helpers.Base.Abstractions;
|
||||
using ADS.Helpers.MultiTenant.Context;
|
||||
using ADS.Helpers.NotifyCenter.Abstractions;
|
||||
using ADS.Helpers.NotifyCenter.Models;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
|
||||
|
||||
public sealed class SmtpNotificationProvider : INotificationProvider
|
||||
{
|
||||
private readonly INotificationSettingsProvider _settingsProvider;
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
private readonly IAppLogger _log;
|
||||
|
||||
public NotificationChannel Channel => NotificationChannel.SmtpEmail;
|
||||
public string Name => "SMTP";
|
||||
|
||||
public SmtpNotificationProvider(
|
||||
INotificationSettingsProvider settingsProvider,
|
||||
ITenantContextAccessor tenantAccessor,
|
||||
IAppLogger log)
|
||||
{
|
||||
_settingsProvider = settingsProvider;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default)
|
||||
{
|
||||
var tenant = _tenantAccessor.Current;
|
||||
|
||||
if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
|
||||
{
|
||||
var dbg = "TenantContext is not available for SMTP provider.";
|
||||
|
||||
_log.Error(
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.TenantContextMissing,
|
||||
message: dbg,
|
||||
ex: null,
|
||||
data: new { message.TraceId });
|
||||
|
||||
return NotificationResult.Fail(
|
||||
providerName: Name,
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.TenantContextMissing,
|
||||
debugMessage: dbg);
|
||||
}
|
||||
|
||||
var opts = await _settingsProvider.GetAsync<SmtpSettings>(tenant.TenantId, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0 || string.IsNullOrWhiteSpace(opts.FromEmail))
|
||||
{
|
||||
_log.Warn(
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.SmtpNotConfigured,
|
||||
message: "SMTP settings are missing or invalid.",
|
||||
data: new { message.TraceId, tenant.TenantId });
|
||||
|
||||
return NotificationResult.Fail(
|
||||
providerName: Name,
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.SmtpNotConfigured,
|
||||
debugMessage: "Host/Port/FromEmail missing or invalid.");
|
||||
}
|
||||
|
||||
var mime = new MimeKit.MimeMessage();
|
||||
mime.From.Add(new MimeKit.MailboxAddress(opts.FromName ?? "", opts.FromEmail));
|
||||
|
||||
foreach (var to in message.To.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
mime.To.Add(MimeKit.MailboxAddress.Parse(to));
|
||||
|
||||
foreach (var cc in message.Cc.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
mime.Cc.Add(MimeKit.MailboxAddress.Parse(cc));
|
||||
|
||||
foreach (var bcc in message.Bcc.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
mime.Bcc.Add(MimeKit.MailboxAddress.Parse(bcc));
|
||||
|
||||
mime.Subject = message.Subject ?? "";
|
||||
|
||||
var bodyBuilder = new MimeKit.BodyBuilder
|
||||
{
|
||||
TextBody = message.BodyText,
|
||||
HtmlBody = message.BodyHtml
|
||||
};
|
||||
mime.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient { Timeout = opts.TimeoutMs };
|
||||
|
||||
SecureSocketOptions sec;
|
||||
if (opts.UseSsl) sec = SecureSocketOptions.SslOnConnect;
|
||||
else if (opts.UseStartTls) sec = SecureSocketOptions.StartTls;
|
||||
else sec = SecureSocketOptions.None;
|
||||
|
||||
await client.ConnectAsync(opts.Host, opts.Port, sec, ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(opts.Username))
|
||||
await client.AuthenticateAsync(opts.Username, opts.Password, ct);
|
||||
|
||||
await client.SendAsync(mime, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
_log.Info(
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.Ok,
|
||||
message: "SMTP message sent.",
|
||||
data: new { message.TraceId, tenant.TenantId });
|
||||
|
||||
return NotificationResult.Ok(
|
||||
providerName: Name,
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.Ok,
|
||||
providerMessageId: null,
|
||||
debugMessage: "SMTP message sent.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.SmtpSendFailed,
|
||||
message: "SMTP send failed.",
|
||||
ex: ex,
|
||||
data: new { message.TraceId, tenant.TenantId });
|
||||
|
||||
return NotificationResult.Fail(
|
||||
providerName: Name,
|
||||
source: MessageSources.SmtpProvider,
|
||||
code: NotifyCodes.SmtpSendFailed,
|
||||
debugMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
|
||||
|
||||
public sealed class SmtpOptions
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
|
||||
public bool UseSsl { get; set; } = false; // implicit SSL (SMTPS)
|
||||
public bool UseStartTls { get; set; } = true; // STARTTLS
|
||||
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
public string FromEmail { get; set; } = "";
|
||||
public string FromName { get; set; } = "";
|
||||
|
||||
// Optional timeout
|
||||
public int TimeoutMs { get; set; } = 15000;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
|
||||
|
||||
public sealed class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
|
||||
public bool UseSsl { get; set; } = false;
|
||||
public bool UseStartTls { get; set; } = true;
|
||||
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
public string FromEmail { get; set; } = "";
|
||||
public string FromName { get; set; } = "";
|
||||
|
||||
public int TimeoutMs { get; set; } = 15000;
|
||||
}
|
||||
137
Helpers/ADS.Helpers.sln
Normal file
137
Helpers/ADS.Helpers.sln
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Base", "ADS.Helpers.Base\ADS.Helpers.Base.csproj", "{B582C882-7C6B-4B65-8517-35335E223626}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.MultiTenant", "ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj", "{B57DB90D-2835-4BCC-9319-98F0622A6D33}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Middleware", "ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj", "{92CA3613-2F92-493E-88E3-EE24DA21CF67}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.NotifyCenter", "ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj", "{CBD7148D-5203-4A08-9FF1-1EE774495417}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Localization", "ADS.Helpers.Localization\ADS.Helpers.Localization.csproj", "{1B443C1C-500B-45F3-BBC0-3C95F741C976}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Tests", "tests\ADS.Helpers.Tests\ADS.Helpers.Tests.csproj", "{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Auth", "ADS.Helpers.Auth\ADS.Helpers.Auth.csproj", "{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Data", "ADS.Helpers.Data\ADS.Helpers.Data.csproj", "{0889FF69-94B0-47A7-97CB-59130D05243F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.Build.0 = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.Build.0 = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
37
Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj
Normal file
37
Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj
Normal file
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||
<ProjectReference Include="..\..\ADS.Helpers.Localization\ADS.Helpers.Localization.csproj" />
|
||||
<ProjectReference Include="..\..\ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj" />
|
||||
<ProjectReference Include="..\..\ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs
Normal file
10
Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ADS.Helpers.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user