Ввести доменные исключения и ресурсы текстов ошибок
DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.SharedKernel.Resources;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
public sealed class DealExceptionHandler(ILogger<DealExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
(int statusCode, string errorCode, string detail) = Resolve(exception);
|
||||
LogFailure(httpContext, exception, statusCode, errorCode);
|
||||
httpContext.Response.StatusCode = statusCode;
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
new { detail, code = errorCode },
|
||||
cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
|
||||
private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
|
||||
=> exception is DealException dealException
|
||||
? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
|
||||
: (StatusCodes.Status500InternalServerError,
|
||||
DealErrorCodes.Internal,
|
||||
ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
|
||||
|
||||
// Код ошибки Deal → статус HTTP.
|
||||
private static int MapStatusCode(string errorCode) => errorCode switch
|
||||
{
|
||||
DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
|
||||
DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
|
||||
DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
|
||||
DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
|
||||
_ => StatusCodes.Status500InternalServerError,
|
||||
};
|
||||
|
||||
// Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
|
||||
private void LogFailure(
|
||||
HttpContext context,
|
||||
Exception exception,
|
||||
int statusCode,
|
||||
string errorCode)
|
||||
{
|
||||
string method = context.Request.Method;
|
||||
string path = context.Request.Path.Value ?? "/";
|
||||
string tenantId = ResolveTenantId(context);
|
||||
if (exception is DealException dealException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
errorCode,
|
||||
tenantId,
|
||||
context.TraceIdentifier,
|
||||
dealException.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
exception,
|
||||
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
errorCode,
|
||||
tenantId,
|
||||
context.TraceIdentifier);
|
||||
}
|
||||
|
||||
// Идентификатор тенанта запроса; вне tenant-запроса — "-".
|
||||
private static string ResolveTenantId(HttpContext context)
|
||||
{
|
||||
ITenantContext? tenantContext = context.RequestServices?.GetService<ITenantContext>();
|
||||
return tenantContext?.TenantId?.Value ?? "-";
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
|
||||
builder.Services.AddDealPersistence(tenantLimitDefaults);
|
||||
|
||||
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
||||
builder.Services.AddExceptionHandler<DealExceptionHandler>();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
||||
builder.Services.AddSingleton(mlOptions);
|
||||
@@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
|
||||
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
||||
}
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAccessLogMiddleware>();
|
||||
|
||||
app.UseCors(corsPolicyName);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.SharedKernel.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Коды ошибок Deal для логов и ответов клиенту.
|
||||
/// </summary>
|
||||
public static class DealErrorCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Запрошенный объект не найден.
|
||||
/// </summary>
|
||||
public const string NotFound = "not_found";
|
||||
|
||||
/// <summary>
|
||||
/// Некорректные данные запроса.
|
||||
/// </summary>
|
||||
public const string Validation = "validation_error";
|
||||
|
||||
/// <summary>
|
||||
/// Конфликт состояния.
|
||||
/// </summary>
|
||||
public const string Conflict = "conflict";
|
||||
|
||||
/// <summary>
|
||||
/// Внешний сервис недоступен.
|
||||
/// </summary>
|
||||
public const string Unavailable = "unavailable";
|
||||
|
||||
/// <summary>
|
||||
/// Непредвиденная внутренняя ошибка.
|
||||
/// </summary>
|
||||
public const string Internal = "internal_error";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Deal.SharedKernel.Resources;
|
||||
|
||||
namespace Deal.SharedKernel.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// База доменных исключений Deal: код ошибки и текст из ресурсов.
|
||||
/// </summary>
|
||||
public abstract class DealException : Exception
|
||||
{
|
||||
protected DealException(
|
||||
string errorCode,
|
||||
string messageKey,
|
||||
params object?[] messageArgs)
|
||||
: base(ErrorResources.Format(messageKey, messageArgs))
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
protected DealException(
|
||||
string errorCode,
|
||||
Exception innerException,
|
||||
string messageKey,
|
||||
params object?[] messageArgs)
|
||||
: base(ErrorResources.Format(messageKey, messageArgs), innerException)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки для логов и ответов клиенту.
|
||||
/// </summary>
|
||||
public string ErrorCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.SharedKernel.Resources;
|
||||
|
||||
namespace Deal.SharedKernel.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Запрошенный объект не найден.
|
||||
/// </summary>
|
||||
public sealed class NotFoundException : DealException
|
||||
{
|
||||
public NotFoundException(string entityName)
|
||||
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName)
|
||||
{
|
||||
}
|
||||
|
||||
public NotFoundException(string entityName, string entityId)
|
||||
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.SharedKernel.Resources;
|
||||
|
||||
namespace Deal.SharedKernel.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Внешний сервис недоступен.
|
||||
/// </summary>
|
||||
public sealed class ServiceUnavailableException : DealException
|
||||
{
|
||||
public ServiceUnavailableException(string serviceName)
|
||||
: base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName)
|
||||
{
|
||||
}
|
||||
|
||||
public ServiceUnavailableException(string serviceName, Exception innerException)
|
||||
: base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Deal.SharedKernel.Resources;
|
||||
|
||||
namespace Deal.SharedKernel.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Некорректные данные запроса.
|
||||
/// </summary>
|
||||
public sealed class ValidationException : DealException
|
||||
{
|
||||
public ValidationException(string messageKey, params object?[] messageArgs)
|
||||
: base(DealErrorCodes.Validation, messageKey, messageArgs)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="UnexpectedError" xml:space="preserve">
|
||||
<value>Внутренняя ошибка сервиса. Обратитесь в поддержку.</value>
|
||||
</data>
|
||||
<data name="NotFoundEntity" xml:space="preserve">
|
||||
<value>Объект не найден: {0}.</value>
|
||||
</data>
|
||||
<data name="NotFoundEntityWithId" xml:space="preserve">
|
||||
<value>Объект не найден: {0} (id: {1}).</value>
|
||||
</data>
|
||||
<data name="ValidationFailed" xml:space="preserve">
|
||||
<value>Некорректные данные запроса: {0}.</value>
|
||||
</data>
|
||||
<data name="ConflictState" xml:space="preserve">
|
||||
<value>Конфликт состояния: {0}.</value>
|
||||
</data>
|
||||
<data name="ServiceUnavailable" xml:space="preserve">
|
||||
<value>Сервис «{0}» временно недоступен.</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Deal.SharedKernel.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
|
||||
/// </summary>
|
||||
public static class ErrorResourceKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Общая непредвиденная внутренняя ошибка.
|
||||
/// </summary>
|
||||
public const string UnexpectedError = "UnexpectedError";
|
||||
|
||||
/// <summary>
|
||||
/// Объект не найден (без идентификатора).
|
||||
/// </summary>
|
||||
public const string NotFoundEntity = "NotFoundEntity";
|
||||
|
||||
/// <summary>
|
||||
/// Объект не найден (с идентификатором).
|
||||
/// </summary>
|
||||
public const string NotFoundEntityWithId = "NotFoundEntityWithId";
|
||||
|
||||
/// <summary>
|
||||
/// Некорректные данные запроса.
|
||||
/// </summary>
|
||||
public const string ValidationFailed = "ValidationFailed";
|
||||
|
||||
/// <summary>
|
||||
/// Конфликт состояния.
|
||||
/// </summary>
|
||||
public const string ConflictState = "ConflictState";
|
||||
|
||||
/// <summary>
|
||||
/// Внешний сервис недоступен.
|
||||
/// </summary>
|
||||
public const string ServiceUnavailable = "ServiceUnavailable";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Globalization;
|
||||
using System.Resources;
|
||||
|
||||
namespace Deal.SharedKernel.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
|
||||
/// </summary>
|
||||
public static class ErrorResources
|
||||
{
|
||||
private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
|
||||
|
||||
private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
|
||||
|
||||
/// <summary>
|
||||
/// Форматированный текст по ключу ресурса с подстановкой аргументов.
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ ресурса (см. <see cref="ErrorResourceKeys"/>).</param>
|
||||
/// <param name="args">Аргументы шаблона.</param>
|
||||
/// <returns>Текст ресурса; неизвестный ключ возвращается как есть.</returns>
|
||||
public static string Format(string key, params object?[] args)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
string? template = Manager.GetString(key, CultureInfo.CurrentUICulture);
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
return args.Length == 0
|
||||
? template
|
||||
: string.Format(CultureInfo.CurrentUICulture, template, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Middleware;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты обработчика необработанных исключений HTTP.
|
||||
/// </summary>
|
||||
public sealed class DealExceptionHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
|
||||
{
|
||||
DefaultHttpContext context = CreateContext();
|
||||
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||
|
||||
bool handled = await handler.TryHandleAsync(
|
||||
context,
|
||||
new NotFoundException("Карточка", "c_1"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
|
||||
(string detail, string code) = await ReadBodyAsync(context);
|
||||
Assert.Equal(DealErrorCodes.NotFound, code);
|
||||
Assert.Contains("Карточка", detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unavailable_MapsTo503()
|
||||
{
|
||||
DefaultHttpContext context = CreateContext();
|
||||
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||
|
||||
await handler.TryHandleAsync(
|
||||
context,
|
||||
new ServiceUnavailableException("ИИ"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
|
||||
{
|
||||
DefaultHttpContext context = CreateContext();
|
||||
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
|
||||
|
||||
bool handled = await handler.TryHandleAsync(
|
||||
context,
|
||||
new InvalidOperationException("секретная внутренняя деталь"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
|
||||
(string detail, string code) = await ReadBodyAsync(context);
|
||||
Assert.Equal(DealErrorCodes.Internal, code);
|
||||
Assert.DoesNotContain("секретная внутренняя деталь", detail);
|
||||
Assert.DoesNotContain("at ", detail);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateContext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
|
||||
{
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
string json = await reader.ReadToEndAsync();
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
return (
|
||||
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
|
||||
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Deal.SharedKernel.Resources;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
|
||||
/// </summary>
|
||||
public sealed class ErrorResourcesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Format_KnownKey_ReturnsRussianText()
|
||||
{
|
||||
string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError);
|
||||
|
||||
Assert.Contains("Внутренняя ошибка", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_TemplateWithArgs_SubstitutesPlaceholders()
|
||||
{
|
||||
string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1");
|
||||
|
||||
Assert.Contains("Карточка", text);
|
||||
Assert.Contains("c_1", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_UnknownKey_ReturnsKey()
|
||||
{
|
||||
string text = ErrorResources.Format("NoSuchKey");
|
||||
|
||||
Assert.Equal("NoSuchKey", text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user