From 4f8e21628266186625e6b933b05c4d27cbc9c817 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 4 Aug 2026 09:03:41 +0800 Subject: [PATCH] refactor(api): modularize problem details mapping --- Tiku.Api/Configuration/DependencyInjection.cs | 3 +- .../ExceptionProblemDetailsExtensions.cs | 29 + .../Middleware/ExceptionHandlingMiddleware.cs | 677 +----------------- .../ExceptionProblemDetailsMapping.cs | 43 ++ .../PlatformExceptionProblemDetailsMapper.cs | 74 ++ .../ContentExceptionProblemDetailsMapper.cs | 88 +++ .../LearningExceptionProblemDetailsMapper.cs | 34 + .../AuthExceptionProblemDetailsMapper.cs | 51 ++ .../JobsExceptionProblemDetailsMapper.cs | 21 + .../StorageExceptionProblemDetailsMapper.cs | 20 + .../CommerceExceptionProblemDetailsMapper.cs | 80 +++ ...enantAdminExceptionProblemDetailsMapper.cs | 24 + .../TenancyExceptionProblemDetailsMapper.cs | 54 ++ .../Api/ExceptionHandlingMiddlewareTests.cs | 21 +- 14 files changed, 563 insertions(+), 656 deletions(-) create mode 100644 Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs create mode 100644 Tiku.Api/Middleware/ExceptionProblemDetailsMapping.cs create mode 100644 Tiku.Api/Modules/Platform/Errors/PlatformExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/System/Auth/Errors/AuthExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/System/Jobs/Errors/JobsExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/System/Storage/Errors/StorageExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/Tenant/Commerce/Errors/CommerceExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/Tenant/Management/Errors/TenantAdminExceptionProblemDetailsMapper.cs create mode 100644 Tiku.Api/Modules/Tenant/Tenancy/Errors/TenancyExceptionProblemDetailsMapper.cs diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs index 8f44516..c9458d6 100644 --- a/Tiku.Api/Configuration/DependencyInjection.cs +++ b/Tiku.Api/Configuration/DependencyInjection.cs @@ -23,6 +23,7 @@ public static class DependencyInjection true); builder.Services.AddApiPresentation(); + builder.Services.AddExceptionProblemDetailsMappers(); builder.Services.AddHealthChecks(); builder.Services.AddApiObservability(builder.Configuration, builder.Environment); builder.Services.AddApplication(); @@ -82,4 +83,4 @@ public static class DependencyInjection return builder; } -} \ No newline at end of file +} diff --git a/Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs b/Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs new file mode 100644 index 0000000..c65abab --- /dev/null +++ b/Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs @@ -0,0 +1,29 @@ +using Tiku.Api.Middleware; +using Tiku.Api.Modules.Platform.Errors; +using Tiku.Api.Modules.Student.Content.Errors; +using Tiku.Api.Modules.Student.Learning.Errors; +using Tiku.Api.Modules.System.Auth.Errors; +using Tiku.Api.Modules.System.Jobs.Errors; +using Tiku.Api.Modules.System.Storage.Errors; +using Tiku.Api.Modules.Tenant.Commerce.Errors; +using Tiku.Api.Modules.Tenant.Management.Errors; +using Tiku.Api.Modules.Tenant.Tenancy.Errors; + +namespace Tiku.Api.Configuration; + +internal static class ExceptionProblemDetailsExtensions +{ + internal static IServiceCollection AddExceptionProblemDetailsMappers(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 2895015..615baac 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -1,31 +1,15 @@ using Microsoft.AspNetCore.Mvc; -using Tiku.Api.Controllers; -using Tiku.Application.Assets; -using Tiku.Application.Auth; -using Tiku.Application.Backoffice; -using Tiku.Application.Commerce; -using Tiku.Application.Content; -using Tiku.Application.Growth; -using Tiku.Application.Jobs; -using Tiku.Application.Learning; -using Tiku.Application.PlatformAdmin; -using Tiku.Application.PlatformBilling; -using Tiku.Application.Points; -using Tiku.Application.Profile; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Scoreline; -using Tiku.Application.Security; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Application.TenantAdmin; namespace Tiku.Api.Middleware; -public sealed class ExceptionHandlingMiddleware( +internal sealed class ExceptionHandlingMiddleware( RequestDelegate next, ILogger logger, - IHostEnvironment environment) + IHostEnvironment environment, + IEnumerable mappers) { + private readonly IReadOnlyCollection problemDetailsMappers = mappers.ToArray(); + public async Task InvokeAsync(HttpContext context) { try @@ -34,416 +18,22 @@ public sealed class ExceptionHandlingMiddleware( } catch (Exception exception) { - if (exception is PlatformApprovalException approvalException) + var mappings = problemDetailsMappers + .Select(mapper => mapper.TryMap(exception, out var mapping) ? mapping : null) + .Where(mapping => mapping is not null) + .Cast() + .ToArray(); + if (mappings.Length == 1) { - var status = approvalException.Code switch - { - "approval_request_not_found" or "approval_policy_not_found" => StatusCodes.Status404NotFound, - "platform_access_denied" or "approval_business_permission_required" or "approval_cancel_denied" => - StatusCodes.Status403Forbidden, - "approval_request_not_pending" or "approval_request_expired" or "approval_maker_checker_required" - or "idempotency_conflict" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - await WriteProblemAsync(context, approvalException.Message, status, approvalException.Code); + await WriteProblemAsync(context, mappings[0]); return; } - if (exception is AuthorizationSecurityUnavailableException) - { - await WriteProblemAsync( - context, - "Authentication security service is unavailable.", - StatusCodes.Status503ServiceUnavailable, - "auth_security_unavailable"); - return; - } - - if (exception is AuthException authException) - { - await WriteAuthProblemAsync(context, authException); - return; - } - - if (exception is OwnerActivationException ownerActivationException) - { - var status = ownerActivationException.Code is "owner_activation_consumed" - ? StatusCodes.Status409Conflict - : StatusCodes.Status400BadRequest; - await WriteProblemAsync(context, ownerActivationException.Message, status, - ownerActivationException.Code); - return; - } - - if (exception is TenantNotFoundException) - { - await WriteProblemAsync( - context, - "Tenant was not found.", - StatusCodes.Status404NotFound, - "tenant_not_found"); - return; - } - - if (exception is TenantLifecycleException lifecycleException) - { - var status = lifecycleException.Code switch - { - "tenant_not_found" => StatusCodes.Status404NotFound, - "tenant_export_not_ready" => StatusCodes.Status409Conflict, - "tenant_archive_blocked" or "tenant_not_archived" or - "tenant_owner_target_not_active_member" - or "tenant_owner_unchanged" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - await WriteProblemAsync(context, lifecycleException.Message, status, lifecycleException.Code); - return; - } - - if (exception is BrowserOriginException) - { - await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, - "browser_origin_rejected"); - return; - } - - if (exception is FeatureAccessException featureAccessException) - { - await WriteProblemAsync( - context, - featureAccessException.Message, - featureAccessException.Code == "feature_quota_exhausted" - ? StatusCodes.Status409Conflict - : StatusCodes.Status403Forbidden, - featureAccessException.Code); - return; - } - - if (exception is BackgroundJobException backgroundJobException) - { - var status = backgroundJobException.Code switch - { - "background_job_not_found" => StatusCodes.Status404NotFound, - "background_job_not_cancellable" or "background_job_not_retryable" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - await WriteProblemAsync( - context, - backgroundJobException.Message, - status, - backgroundJobException.Code); - return; - } - - if (exception is TenantContextConflictException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status403Forbidden, - "tenant_context_conflict"); - return; - } - - if (exception is TenantFrontendConfigException frontendConfigException) - { - var status = frontendConfigException.Code switch - { - "tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound, - "frontend_config_version_conflict" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - await WriteProblemAsync( - context, - frontendConfigException.Message, - status, - frontendConfigException.Code); - return; - } - - if (exception is PublicQuestionAccessDeniedException publicQuestionAccessDeniedException) - { - await WriteProblemAsync( - context, - publicQuestionAccessDeniedException.Message, - StatusCodes.Status403Forbidden, - publicQuestionAccessDeniedException.Code); - return; - } - - if (exception is QuestionLocatorException questionLocatorException) - { - await WriteProblemAsync( - context, - questionLocatorException.Message, - StatusCodes.Status404NotFound, - questionLocatorException.Code); - return; - } - - if (exception is RequiredFieldException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status400BadRequest, - "required_field"); - return; - } - - if (exception is ContentNavigationNotFoundException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status404NotFound, - "content_navigation_not_found"); - return; - } - - if (exception is QuestionBankRequiredFieldException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status400BadRequest, - "required_field"); - return; - } - - if (exception is QuestionBankNotFoundException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status404NotFound, - "question_not_found"); - return; - } - - if (exception is AssetAccessException assetAccessException) - { - await WriteProblemAsync( - context, - assetAccessException.Message, - AssetAccessStatusCode(assetAccessException.Code), - assetAccessException.Code.ToLowerInvariant()); - return; - } - - if (exception is AssetManagementException assetManagementException) - { - await WriteProblemAsync( - context, - assetManagementException.Message, - AssetManagementStatusCode(assetManagementException.Code), - assetManagementException.Code); - return; - } - - if (exception is VideoPlaybackException videoPlaybackException) - { - await WriteProblemAsync( - context, - videoPlaybackException.Message, - VideoPlaybackStatusCode(videoPlaybackException.Code), - videoPlaybackException.Code); - return; - } - - if (exception is ContentManagementException contentManagementException) - { - await WriteProblemAsync( - context, - contentManagementException.Message, - ContentManagementStatusCode(contentManagementException.Code), - contentManagementException.Code); - return; - } - - if (exception is LearningValidationException learningValidationException) - { - await WriteProblemAsync( - context, - learningValidationException.Message, - LearningValidationStatusCode(learningValidationException.Code), - learningValidationException.Code); - return; - } - - if (exception is LearningResourceNotFoundException learningResourceNotFoundException) - { - await WriteProblemAsync( - context, - learningResourceNotFoundException.Message, - StatusCodes.Status404NotFound, - learningResourceNotFoundException.Code); - return; - } - - if (exception is LearningAccessDeniedException) - { - await WriteProblemAsync( - context, - exception.Message, - StatusCodes.Status403Forbidden, - "learning_access_denied"); - return; - } - - if (exception is ScorelineQueryException scorelineQueryException) - { - await WriteProblemAsync( - context, - scorelineQueryException.Message, - StatusCodes.Status400BadRequest, - scorelineQueryException.Code); - return; - } - - if (exception is ProfileException profileException) - { - await WriteProblemAsync( - context, - profileException.Message, - ProfileStatusCode(profileException.Code), - profileException.Code); - return; - } - - if (exception is TenantAdminDirectException tenantAdminDirectException) - { - await WriteProblemAsync( - context, - tenantAdminDirectException.Message, - TenantAdminDirectStatusCode(tenantAdminDirectException.Code), - tenantAdminDirectException.Code); - return; - } - - if (exception is BackofficeException backofficeException) - { - await WriteProblemAsync( - context, - backofficeException.Message, - BackofficeStatusCode(backofficeException.Code), - backofficeException.Code); - return; - } - - if (exception is PlatformAdminException platformAdminException) - { - await WriteProblemAsync( - context, - platformAdminException.Message, - PlatformAdminStatusCode(platformAdminException.Code), - platformAdminException.Code); - return; - } - - if (exception is PlatformBillingException platformBillingException) - { - await WriteProblemAsync( - context, - platformBillingException.Message, - PlatformBillingStatusCode(platformBillingException.Code), - platformBillingException.Code); - return; - } - - if (exception is CommerceException commerceException) - { - await WriteProblemAsync( - context, - commerceException.Message, - CommerceStatusCode(commerceException.Code), - commerceException.Code); - return; - } - - if (exception is PointException pointException) - { - await WriteProblemAsync( - context, - pointException.Message, - PointStatusCode(pointException.Code), - pointException.Code); - return; - } - - if (exception is ReferralException referralException) - { - await WriteProblemAsync( - context, - referralException.Message, - ReferralStatusCode(referralException.Code), - referralException.Code); - return; - } - - if (exception is CrmException crmException) - { - await WriteProblemAsync( - context, - crmException.Message, - CrmStatusCode(crmException.Code), - crmException.Code); - return; - } - - if (exception is CommissionException commissionException) - { - await WriteProblemAsync( - context, - commissionException.Message, - CommissionStatusCode(commissionException.Code), - commissionException.Code); - return; - } - - if (exception is PaymentProviderException paymentProviderException) - { - await WriteProblemAsync( - context, - paymentProviderException.Message, - CommerceStatusCode(paymentProviderException.Code), - paymentProviderException.Code); - return; - } - - if (exception is TenantExternalProviderException externalProviderException) - { - await WriteProblemAsync( - context, - externalProviderException.Message, - StatusCodes.Status400BadRequest, - externalProviderException.Code); - return; - } - - if (exception is PlatformCapabilityException platformCapabilityException) - { - await WriteProblemAsync( - context, - platformCapabilityException.Message, - StatusCodes.Status400BadRequest, - platformCapabilityException.Code); - return; - } - - if (exception is ObjectStorageException storageException) - { - await WriteProblemAsync( - context, - storageException.Message, - storageException is ObjectStorageNotConfiguredException - ? StatusCodes.Status503ServiceUnavailable - : StatusCodes.Status400BadRequest, - storageException.Code.ToLowerInvariant()); - return; - } - - logger.LogError(exception, "Unhandled API exception"); + if (mappings.Length > 1) + logger.LogCritical(exception, "Multiple ProblemDetails mappers matched {ExceptionType}", + exception.GetType().FullName); + else + logger.LogError(exception, "Unhandled API exception"); var problem = new ProblemDetails { @@ -452,7 +42,6 @@ public sealed class ExceptionHandlingMiddleware( Detail = environment.IsDevelopment() ? exception.Message : null, Instance = context.Request.Path }; - problem.Extensions["traceId"] = context.TraceIdentifier; context.Response.StatusCode = StatusCodes.Status500InternalServerError; await context.Response.WriteAsJsonAsync(problem); @@ -461,236 +50,18 @@ public sealed class ExceptionHandlingMiddleware( private static async Task WriteProblemAsync( HttpContext context, - string title, - int status, - string code) + ExceptionProblemDetailsMapping mapping) { var problem = new ProblemDetails { - Title = title, - Status = status, + Title = mapping.Title, + Status = mapping.Status, Instance = context.Request.Path }; - - problem.Extensions["code"] = code; + problem.Extensions["code"] = mapping.Code; problem.Extensions["traceId"] = context.TraceIdentifier; - context.Response.StatusCode = status; + foreach (var extension in mapping.Extensions) problem.Extensions[extension.Key] = extension.Value; + context.Response.StatusCode = mapping.Status; await context.Response.WriteAsJsonAsync(problem); } - - private static async Task WriteAuthProblemAsync(HttpContext context, AuthException exception) - { - var status = exception.Code switch - { - "tenant_access_denied" => StatusCodes.Status403Forbidden, - "auth_session_not_found" => StatusCodes.Status404NotFound, - "current_auth_session_cannot_be_revoked" => StatusCodes.Status409Conflict, - "sms_rate_limited" => StatusCodes.Status429TooManyRequests, - "auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable, - "auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable, - "session_revoked" => StatusCodes.Status401Unauthorized, - _ => StatusCodes.Status401Unauthorized - }; - var problem = new ProblemDetails - { - Title = exception.Message, - Status = status, - Instance = context.Request.Path - }; - - problem.Extensions["code"] = exception.Code; - problem.Extensions["traceId"] = context.TraceIdentifier; - context.Response.StatusCode = status; - await context.Response.WriteAsJsonAsync(problem); - } - - private static int AssetAccessStatusCode(string code) - { - return code switch - { - "ASSET_NOT_FOUND" => StatusCodes.Status404NotFound, - "AUTH_REQUIRED" => StatusCodes.Status401Unauthorized, - "ASSET_HIDDEN" or "ASSET_MEMBERSHIP_REQUIRED" or "ASSET_SVIP_REQUIRED" => StatusCodes.Status403Forbidden, - "ASSET_UPLOAD_NOT_VERIFIED" or "ASSET_SECURITY_SCAN_NOT_PASSED" => StatusCodes.Status409Conflict, - "ASSET_PREVIEW_NOT_SUPPORTED" => StatusCodes.Status400BadRequest, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int LearningValidationStatusCode(string code) - { - return code switch - { - "no_practice_questions" or "practice_session_empty" or - "idempotency_conflict" or "practice_session_version_conflict" or - "practice_client_sequence_conflict" or "practice_answer_conflict" or - "practice_submission_conflict" or "practice_session_not_active" or - "practice_session_expired" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int AssetManagementStatusCode(string code) - { - return code switch - { - "asset_not_found" or "import_job_not_found" => StatusCodes.Status404NotFound, - "asset_upload_missing" => StatusCodes.Status409Conflict, - "tenant_content_access_denied" => StatusCodes.Status403Forbidden, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int ContentManagementStatusCode(string code) - { - return code switch - { - "entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or - "import_type_invalid" => StatusCodes.Status404NotFound, - "tenant_content_access_denied" => StatusCodes.Status403Forbidden, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int ProfileStatusCode(string code) - { - return code switch - { - "profile_access_denied" => StatusCodes.Status403Forbidden, - "profile_user_not_found" or "check_in_task_not_found" => StatusCodes.Status404NotFound, - "region_not_found" or "school_not_found" or "major_not_found" => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int VideoPlaybackStatusCode(string code) - { - return code switch - { - "video_access_denied" => StatusCodes.Status403Forbidden, - "video_not_found" or "question_video_not_found" => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int TenantAdminDirectStatusCode(string code) - { - return code switch - { - "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, - "class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes - .Status404NotFound, - "tenant_member_not_found" => StatusCodes.Status404NotFound, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int CommerceStatusCode(string code) - { - return code switch - { - "commerce_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, - "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, - "order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" or - "coupon_not_found" or "coupon_redemption_not_found" => StatusCodes.Status404NotFound, - "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes - .Status503ServiceUnavailable, - "order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or - "coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int BackofficeStatusCode(string code) - { - return code switch - { - "platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes - .Status403Forbidden, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int PlatformAdminStatusCode(string code) - { - return code switch - { - "platform_access_denied" => StatusCodes.Status403Forbidden, - "tenant_slug_exists" or "idempotency_conflict" or "owner_activation_already_issued" or - "owner_already_activated" => StatusCodes.Status409Conflict, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int PlatformBillingStatusCode(string code) - { - return code switch - { - "tenant_not_found" => StatusCodes.Status404NotFound, - "platform_billing_public_url_missing" or "payment_provider_not_configured" or - "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, - "saas_offering_version_immutable" or "saas_offering_version_status_invalid" or - "platform_billing_quote_expired" or "platform_billing_order_status_invalid" or - "platform_billing_payment_status_invalid" or "platform_billing_payment_amount_mismatch" or - "platform_billing_order_not_cancellable" or "platform_billing_payment_not_refundable" or - "platform_billing_refund_amount_invalid" or "platform_billing_refund_not_retryable" or - "platform_billing_refund_status_invalid" or "tenant_saas_subscription_status_invalid" or - "tenant_saas_subscription_exists" or "idempotency_conflict" => - StatusCodes.Status409Conflict, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int PointStatusCode(string code) - { - return code switch - { - "point_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, - "point_task_not_found" or "point_exchange_item_not_found" => StatusCodes.Status404NotFound, - "point_task_claim_limit_reached" or "insufficient_points" or "point_exchange_item_sold_out" => - StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int ReferralStatusCode(string code) - { - return code switch - { - "referral_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, - "referral_code_not_found" => StatusCodes.Status404NotFound, - "referral_lead_protected" or "self_referral_not_allowed" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int CrmStatusCode(string code) - { - return code switch - { - "crm_access_denied" => StatusCodes.Status403Forbidden, - "crm_queue_not_found" => StatusCodes.Status404NotFound, - "crm_queue_status_invalid" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - } - - private static int CommissionStatusCode(string code) - { - return code switch - { - "commission_access_denied" => StatusCodes.Status403Forbidden, - "commission_member_not_found" or "commission_settlement_not_found" or "commission_proof_not_found" => - StatusCodes.Status404NotFound, - "commission_no_unsettled_sources" or "commission_below_minimum" or "commission_status_invalid" or - "commission_proof_closed" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status400BadRequest - }; - } -} \ No newline at end of file +} diff --git a/Tiku.Api/Middleware/ExceptionProblemDetailsMapping.cs b/Tiku.Api/Middleware/ExceptionProblemDetailsMapping.cs new file mode 100644 index 0000000..260e19e --- /dev/null +++ b/Tiku.Api/Middleware/ExceptionProblemDetailsMapping.cs @@ -0,0 +1,43 @@ +namespace Tiku.Api.Middleware; + +internal sealed record ExceptionProblemDetailsMapping( + string Title, + int Status, + string Code, + IReadOnlyDictionary Extensions) +{ + public ExceptionProblemDetailsMapping(string title, int status, string code) + : this(title, status, code, new Dictionary()) + { + } +} + +internal interface IExceptionProblemDetailsMapper +{ + IReadOnlyCollection HandledExceptionTypes { get; } + + bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping); +} + +internal abstract class ExceptionProblemDetailsMapper : IExceptionProblemDetailsMapper +{ + public abstract IReadOnlyCollection HandledExceptionTypes { get; } + + public abstract bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping); + + protected static bool Mapped( + Exception exception, + int status, + string code, + out ExceptionProblemDetailsMapping mapping) + { + mapping = new ExceptionProblemDetailsMapping(exception.Message, status, code); + return true; + } + + protected static bool NotMapped(out ExceptionProblemDetailsMapping mapping) + { + mapping = null!; + return false; + } +} diff --git a/Tiku.Api/Modules/Platform/Errors/PlatformExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Platform/Errors/PlatformExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..d8f649b --- /dev/null +++ b/Tiku.Api/Modules/Platform/Errors/PlatformExceptionProblemDetailsMapper.cs @@ -0,0 +1,74 @@ +using Tiku.Api.Middleware; +using Tiku.Application.Backoffice; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.PlatformBilling; + +namespace Tiku.Api.Modules.Platform.Errors; + +internal sealed class PlatformExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [ + typeof(PlatformApprovalException), typeof(BackofficeException), typeof(PlatformAdminException), + typeof(PlatformBillingException), typeof(PlatformCapabilityException) + ]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + PlatformApprovalException approval => Mapped(approval, ApprovalStatus(approval.Code), approval.Code, + out mapping), + BackofficeException backoffice => Mapped(backoffice, BackofficeStatus(backoffice.Code), backoffice.Code, + out mapping), + PlatformAdminException admin => Mapped(admin, AdminStatus(admin.Code), admin.Code, out mapping), + PlatformBillingException billing => Mapped(billing, BillingStatus(billing.Code), billing.Code, out mapping), + PlatformCapabilityException capability => Mapped(capability, StatusCodes.Status400BadRequest, + capability.Code, out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int ApprovalStatus(string code) => code switch + { + "approval_request_not_found" or "approval_policy_not_found" => StatusCodes.Status404NotFound, + "platform_access_denied" or "approval_business_permission_required" or "approval_cancel_denied" => + StatusCodes.Status403Forbidden, + "approval_request_not_pending" or "approval_request_expired" or "approval_maker_checker_required" or + "idempotency_conflict" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + + private static int BackofficeStatus(string code) => code switch + { + "platform_access_denied" or "tenant_access_denied" or "capability_not_available" => + StatusCodes.Status403Forbidden, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + + private static int AdminStatus(string code) => code switch + { + "platform_access_denied" => StatusCodes.Status403Forbidden, + "tenant_slug_exists" or "idempotency_conflict" or "owner_activation_already_issued" or + "owner_already_activated" => StatusCodes.Status409Conflict, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + + private static int BillingStatus(string code) => code switch + { + "tenant_not_found" => StatusCodes.Status404NotFound, + "platform_billing_public_url_missing" or "payment_provider_not_configured" or + "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, + "saas_offering_version_immutable" or "saas_offering_version_status_invalid" or + "platform_billing_quote_expired" or "platform_billing_order_status_invalid" or + "platform_billing_payment_status_invalid" or "platform_billing_payment_amount_mismatch" or + "platform_billing_order_not_cancellable" or "platform_billing_payment_not_refundable" or + "platform_billing_refund_amount_invalid" or "platform_billing_refund_not_retryable" or + "platform_billing_refund_status_invalid" or "tenant_saas_subscription_status_invalid" or + "tenant_saas_subscription_exists" or "idempotency_conflict" => StatusCodes.Status409Conflict, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..d8073b3 --- /dev/null +++ b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs @@ -0,0 +1,88 @@ +using Tiku.Api.Middleware; +using Tiku.Application.Assets; +using Tiku.Application.Content; +using Tiku.Application.Profile; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Scoreline; + +namespace Tiku.Api.Modules.Student.Content.Errors; + +internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [ + typeof(PublicQuestionAccessDeniedException), typeof(QuestionLocatorException), typeof(RequiredFieldException), + typeof(ContentNavigationNotFoundException), typeof(QuestionBankRequiredFieldException), + typeof(QuestionBankNotFoundException), typeof(AssetAccessException), typeof(AssetManagementException), + typeof(VideoPlaybackException), typeof(ContentManagementException), typeof(ScorelineQueryException), + typeof(ProfileException) + ]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + PublicQuestionAccessDeniedException denied => Mapped(denied, StatusCodes.Status403Forbidden, denied.Code, + out mapping), + QuestionLocatorException locator => Mapped(locator, StatusCodes.Status404NotFound, locator.Code, + out mapping), + RequiredFieldException => Mapped(exception, StatusCodes.Status400BadRequest, "required_field", out mapping), + ContentNavigationNotFoundException => Mapped(exception, StatusCodes.Status404NotFound, + "content_navigation_not_found", out mapping), + QuestionBankRequiredFieldException => Mapped(exception, StatusCodes.Status400BadRequest, "required_field", + out mapping), + QuestionBankNotFoundException => Mapped(exception, StatusCodes.Status404NotFound, "question_not_found", + out mapping), + AssetAccessException asset => Mapped(asset, AssetAccessStatus(asset.Code), asset.Code.ToLowerInvariant(), + out mapping), + AssetManagementException asset => Mapped(asset, AssetManagementStatus(asset.Code), asset.Code, out mapping), + VideoPlaybackException video => Mapped(video, VideoStatus(video.Code), video.Code, out mapping), + ContentManagementException content => Mapped(content, ContentStatus(content.Code), content.Code, out mapping), + ScorelineQueryException scoreline => Mapped(scoreline, StatusCodes.Status400BadRequest, scoreline.Code, + out mapping), + ProfileException profile => Mapped(profile, ProfileStatus(profile.Code), profile.Code, out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int AssetAccessStatus(string code) => code switch + { + "ASSET_NOT_FOUND" => StatusCodes.Status404NotFound, + "AUTH_REQUIRED" => StatusCodes.Status401Unauthorized, + "ASSET_HIDDEN" or "ASSET_MEMBERSHIP_REQUIRED" or "ASSET_SVIP_REQUIRED" => StatusCodes.Status403Forbidden, + "ASSET_UPLOAD_NOT_VERIFIED" or "ASSET_SECURITY_SCAN_NOT_PASSED" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + + private static int AssetManagementStatus(string code) => code switch + { + "asset_not_found" or "import_job_not_found" => StatusCodes.Status404NotFound, + "asset_upload_missing" => StatusCodes.Status409Conflict, + "tenant_content_access_denied" => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest + }; + + private static int VideoStatus(string code) => code switch + { + "video_access_denied" => StatusCodes.Status403Forbidden, + "video_not_found" or "question_video_not_found" => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + + private static int ContentStatus(string code) => code switch + { + "entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or + "import_type_invalid" => StatusCodes.Status404NotFound, + "tenant_content_access_denied" => StatusCodes.Status403Forbidden, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + + private static int ProfileStatus(string code) => code switch + { + "profile_access_denied" => StatusCodes.Status403Forbidden, + "profile_user_not_found" or "check_in_task_not_found" or "region_not_found" or "school_not_found" or + "major_not_found" => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..cab0b14 --- /dev/null +++ b/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs @@ -0,0 +1,34 @@ +using Tiku.Api.Middleware; +using Tiku.Api.Controllers; +using Tiku.Application.Learning; + +namespace Tiku.Api.Modules.Student.Learning.Errors; + +internal sealed class LearningExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [typeof(LearningValidationException), typeof(LearningResourceNotFoundException), typeof(LearningAccessDeniedException)]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + LearningValidationException validation => Mapped(validation, ValidationStatus(validation.Code), + validation.Code, out mapping), + LearningResourceNotFoundException notFound => Mapped(notFound, StatusCodes.Status404NotFound, + notFound.Code, out mapping), + LearningAccessDeniedException => Mapped(exception, StatusCodes.Status403Forbidden, + "learning_access_denied", out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int ValidationStatus(string code) => code switch + { + "no_practice_questions" or "practice_session_empty" or "idempotency_conflict" or + "practice_session_version_conflict" or "practice_client_sequence_conflict" or + "practice_answer_conflict" or "practice_submission_conflict" or "practice_session_not_active" or + "practice_session_expired" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/Tiku.Api/Modules/System/Auth/Errors/AuthExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/System/Auth/Errors/AuthExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..13f72bb --- /dev/null +++ b/Tiku.Api/Modules/System/Auth/Errors/AuthExceptionProblemDetailsMapper.cs @@ -0,0 +1,51 @@ +using Tiku.Api.Middleware; +using Tiku.Api.Controllers; +using Tiku.Application.Auth; +using Tiku.Application.Security; + +namespace Tiku.Api.Modules.System.Auth.Errors; + +internal sealed class AuthExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [ + typeof(AuthorizationSecurityUnavailableException), typeof(AuthException), typeof(OwnerActivationException), + typeof(BrowserOriginException), typeof(FeatureAccessException) + ]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + AuthorizationSecurityUnavailableException => Mapped(exception, StatusCodes.Status503ServiceUnavailable, + "auth_security_unavailable", out mapping), + AuthException auth => Mapped(auth, AuthStatus(auth.Code), auth.Code, out mapping), + OwnerActivationException activation => Mapped(activation, + activation.Code == "owner_activation_consumed" + ? StatusCodes.Status409Conflict + : StatusCodes.Status400BadRequest, activation.Code, out mapping), + BrowserOriginException => Mapped(exception, StatusCodes.Status403Forbidden, "browser_origin_rejected", + out mapping), + FeatureAccessException feature => Mapped(feature, + feature.Code == "feature_quota_exhausted" + ? StatusCodes.Status409Conflict + : StatusCodes.Status403Forbidden, feature.Code, out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int AuthStatus(string code) + { + return code switch + { + "tenant_access_denied" => StatusCodes.Status403Forbidden, + "auth_session_not_found" => StatusCodes.Status404NotFound, + "current_auth_session_cannot_be_revoked" => StatusCodes.Status409Conflict, + "sms_rate_limited" => StatusCodes.Status429TooManyRequests, + "auth_provider_not_configured" or "auth_security_unavailable" => + StatusCodes.Status503ServiceUnavailable, + "session_revoked" => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status401Unauthorized + }; + } +} diff --git a/Tiku.Api/Modules/System/Jobs/Errors/JobsExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/System/Jobs/Errors/JobsExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..45ba442 --- /dev/null +++ b/Tiku.Api/Modules/System/Jobs/Errors/JobsExceptionProblemDetailsMapper.cs @@ -0,0 +1,21 @@ +using Tiku.Api.Middleware; +using Tiku.Application.Jobs; + +namespace Tiku.Api.Modules.System.Jobs.Errors; + +internal sealed class JobsExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = [typeof(BackgroundJobException)]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + if (exception is not BackgroundJobException job) return NotMapped(out mapping); + var status = job.Code switch + { + "background_job_not_found" => StatusCodes.Status404NotFound, + "background_job_not_cancellable" or "background_job_not_retryable" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + return Mapped(job, status, job.Code, out mapping); + } +} diff --git a/Tiku.Api/Modules/System/Storage/Errors/StorageExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/System/Storage/Errors/StorageExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..5ebbb5c --- /dev/null +++ b/Tiku.Api/Modules/System/Storage/Errors/StorageExceptionProblemDetailsMapper.cs @@ -0,0 +1,20 @@ +using Tiku.Api.Middleware; +using Tiku.Application.Storage; + +namespace Tiku.Api.Modules.System.Storage.Errors; + +internal sealed class StorageExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = [typeof(ObjectStorageException)]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + if (exception is not ObjectStorageException storage) return NotMapped(out mapping); + return Mapped(storage, + storage is ObjectStorageNotConfiguredException + ? StatusCodes.Status503ServiceUnavailable + : StatusCodes.Status400BadRequest, + storage.Code.ToLowerInvariant(), + out mapping); + } +} diff --git a/Tiku.Api/Modules/Tenant/Commerce/Errors/CommerceExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Tenant/Commerce/Errors/CommerceExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..c84a482 --- /dev/null +++ b/Tiku.Api/Modules/Tenant/Commerce/Errors/CommerceExceptionProblemDetailsMapper.cs @@ -0,0 +1,80 @@ +using Tiku.Api.Middleware; +using Tiku.Application.Commerce; +using Tiku.Application.Growth; +using Tiku.Application.Points; + +namespace Tiku.Api.Modules.Tenant.Commerce.Errors; + +internal sealed class CommerceExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [ + typeof(CommerceException), typeof(PointException), typeof(ReferralException), typeof(CrmException), + typeof(CommissionException), typeof(PaymentProviderException) + ]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + CommerceException commerce => Mapped(commerce, CommerceStatus(commerce.Code), commerce.Code, out mapping), + PaymentProviderException provider => Mapped(provider, CommerceStatus(provider.Code), provider.Code, + out mapping), + PointException point => Mapped(point, PointStatus(point.Code), point.Code, out mapping), + ReferralException referral => Mapped(referral, ReferralStatus(referral.Code), referral.Code, out mapping), + CrmException crm => Mapped(crm, CrmStatus(crm.Code), crm.Code, out mapping), + CommissionException commission => Mapped(commission, CommissionStatus(commission.Code), commission.Code, + out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int CommerceStatus(string code) => code switch + { + "commerce_access_denied" or "tenant_access_denied" or "tenant_admin_access_denied" => + StatusCodes.Status403Forbidden, + "order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" or + "coupon_not_found" or "coupon_redemption_not_found" => StatusCodes.Status404NotFound, + "payment_provider_not_configured" or "payment_secret_not_configured" => + StatusCodes.Status503ServiceUnavailable, + "order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or + "coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + + private static int PointStatus(string code) => code switch + { + "point_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + "point_task_not_found" or "point_exchange_item_not_found" => StatusCodes.Status404NotFound, + "point_task_claim_limit_reached" or "insufficient_points" or "point_exchange_item_sold_out" => + StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + + private static int ReferralStatus(string code) => code switch + { + "referral_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + "referral_code_not_found" => StatusCodes.Status404NotFound, + "referral_lead_protected" or "self_referral_not_allowed" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + + private static int CrmStatus(string code) => code switch + { + "crm_access_denied" => StatusCodes.Status403Forbidden, + "crm_queue_not_found" => StatusCodes.Status404NotFound, + "crm_queue_status_invalid" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + + private static int CommissionStatus(string code) => code switch + { + "commission_access_denied" => StatusCodes.Status403Forbidden, + "commission_member_not_found" or "commission_settlement_not_found" or "commission_proof_not_found" => + StatusCodes.Status404NotFound, + "commission_no_unsettled_sources" or "commission_below_minimum" or "commission_status_invalid" or + "commission_proof_closed" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/Tiku.Api/Modules/Tenant/Management/Errors/TenantAdminExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Tenant/Management/Errors/TenantAdminExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..141f7c3 --- /dev/null +++ b/Tiku.Api/Modules/Tenant/Management/Errors/TenantAdminExceptionProblemDetailsMapper.cs @@ -0,0 +1,24 @@ +using Tiku.Api.Middleware; +using Tiku.Application.TenantAdmin; + +namespace Tiku.Api.Modules.Tenant.Management.Errors; + +internal sealed class TenantAdminExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = [typeof(TenantAdminDirectException)]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + if (exception is not TenantAdminDirectException tenantAdmin) return NotMapped(out mapping); + var status = tenantAdmin.Code switch + { + "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, + "class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" or + "tenant_member_not_found" => StatusCodes.Status404NotFound, + _ when tenantAdmin.Code.EndsWith("_not_found", StringComparison.Ordinal) => + StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + return Mapped(tenantAdmin, status, tenantAdmin.Code, out mapping); + } +} diff --git a/Tiku.Api/Modules/Tenant/Tenancy/Errors/TenancyExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Tenant/Tenancy/Errors/TenancyExceptionProblemDetailsMapper.cs new file mode 100644 index 0000000..a965b74 --- /dev/null +++ b/Tiku.Api/Modules/Tenant/Tenancy/Errors/TenancyExceptionProblemDetailsMapper.cs @@ -0,0 +1,54 @@ +using Tiku.Api.Controllers; +using Tiku.Api.Middleware; +using Tiku.Application.Tenancy; +using Tiku.Application.Security; + +namespace Tiku.Api.Modules.Tenant.Tenancy.Errors; + +internal sealed class TenancyExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper +{ + public override IReadOnlyCollection HandledExceptionTypes { get; } = + [ + typeof(TenantNotFoundException), typeof(TenantLifecycleException), typeof(TenantContextConflictException), + typeof(TenantFrontendConfigException), typeof(TenantExternalProviderException) + ]; + + public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping) + { + return exception switch + { + TenantNotFoundException => Mapped(exception, StatusCodes.Status404NotFound, "tenant_not_found", + out mapping), + TenantLifecycleException lifecycle => Mapped(lifecycle, LifecycleStatus(lifecycle.Code), lifecycle.Code, + out mapping), + TenantContextConflictException => Mapped(exception, StatusCodes.Status403Forbidden, + "tenant_context_conflict", out mapping), + TenantFrontendConfigException frontend => Mapped(frontend, FrontendStatus(frontend.Code), frontend.Code, + out mapping), + TenantExternalProviderException provider => Mapped(provider, StatusCodes.Status400BadRequest, provider.Code, + out mapping), + _ => NotMapped(out mapping) + }; + } + + private static int LifecycleStatus(string code) + { + return code switch + { + "tenant_not_found" => StatusCodes.Status404NotFound, + "tenant_export_not_ready" or "tenant_archive_blocked" or "tenant_not_archived" or + "tenant_owner_target_not_active_member" or "tenant_owner_unchanged" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + } + + private static int FrontendStatus(string code) + { + return code switch + { + "tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound, + "frontend_config_version_conflict" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + } +} diff --git a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs index 3e52c47..ed7cd23 100644 --- a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs +++ b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs @@ -1,9 +1,11 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Tiku.Api.Middleware; +using Tiku.Api.Modules.Platform.Errors; using Tiku.Application.Backoffice; namespace Tiku.IntegrationTests.Api; @@ -25,7 +27,8 @@ public sealed class ExceptionHandlingMiddlewareTests var middleware = new ExceptionHandlingMiddleware( _ => throw new BackofficeException("Backoffice request failed.", code), NullLogger.Instance, - new TestHostEnvironment()); + new TestHostEnvironment(), + [new PlatformExceptionProblemDetailsMapper()]); await middleware.InvokeAsync(context); @@ -40,6 +43,20 @@ public sealed class ExceptionHandlingMiddlewareTests Assert.Equal("backoffice-test-trace", root.GetProperty("traceId").GetString()); } + [Fact] + public async Task Registered_exception_types_have_exactly_one_owner() + { + await using var factory = new ApiTestFactory(); + var ownership = factory.Services.GetServices() + .SelectMany(mapper => mapper.HandledExceptionTypes.Select(type => new { type, mapper })) + .GroupBy(item => item.type) + .Where(group => group.Count() != 1) + .Select(group => group.Key.FullName) + .ToArray(); + + Assert.Empty(ownership); + } + private sealed class TestHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Production; @@ -47,4 +64,4 @@ public sealed class ExceptionHandlingMiddlewareTests public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } -} \ No newline at end of file +}