Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OCC-203: Add FrontendException and use it in SafeJsonAsync #253

Merged
merged 7 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Lombiq.HelpfulLibraries.AspNetCore/Docs/Exceptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Lombiq Helpful Libraries - ASP.NET Core Libraries - Exceptions

- `FrontendException`: An exception whose message is safe to display to the end user of a web application.
65 changes: 65 additions & 0 deletions Lombiq.HelpfulLibraries.AspNetCore/Exceptions/FrontendException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#nullable enable

using Microsoft.AspNetCore.Mvc.Localization;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Lombiq.HelpfulLibraries.AspNetCore.Exceptions;

/// <summary>
/// An exception whose message is safe to display to the end user of a web application.
/// </summary>
/// <remarks><para>Note that the message may contain unencoded HTML content.</para></remarks>
public class FrontendException : Exception
{
/// <summary>
/// The string placed between error messages in the <see cref="Exception.Message"/> property, if there are multiple
/// entries in the <see cref="HtmlMessages"/>.
/// </summary>
public const string MessageSeparator = "<br>";

/// <summary>
/// Gets the list of error messages that can be displayed on the front end.
/// </summary>
public IReadOnlyList<LocalizedHtmlString> HtmlMessages { get; } = ArraySegment<LocalizedHtmlString>.Empty;

public FrontendException(LocalizedHtmlString message, Exception? innerException = null)
: base(message.Value, innerException) =>
HtmlMessages = [message];

public FrontendException(ICollection<LocalizedHtmlString> messages, Exception? innerException = null)
: base(string.Join("<br>", messages.Select(message => message.Value)), innerException) =>
HtmlMessages = messages.ToList();

public FrontendException(string message)
: base(message) =>
HtmlMessages = [new LocalizedHtmlString(message, message)];

public FrontendException()
{
}

public FrontendException(string message, Exception? innerException)
: base(message, innerException) =>
HtmlMessages = [new LocalizedHtmlString(message, message)];

/// <summary>
/// If the provided collection of <paramref name="errors"/> is not empty, it throws an exception with the included
/// texts.
/// </summary>
/// <param name="errors">The possible collection of error texts.</param>
/// <exception cref="FrontendException">The non-empty error messages from <paramref name="errors"/>.</exception>
public static void ThrowIfAny(ICollection<string>? errors) =>
ThrowIfAny(errors?.WhereNot(string.IsNullOrWhiteSpace).Select(error => new LocalizedHtmlString(error, error)).ToList());

/// <inheritdoc cref="ThrowIfAny(System.Collections.Generic.ICollection{string}?)"/>
public static void ThrowIfAny(ICollection<LocalizedHtmlString>? errors)
{
if (errors == null || errors.Count == 0) return;

if (errors.Count == 1) throw new FrontendException(errors.Single());

throw new FrontendException(errors);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using Lombiq.HelpfulLibraries.AspNetCore.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using OrchardCore.ContentManagement;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -33,23 +36,25 @@ public static RedirectResult RedirectToContentDisplay(this Controller controller
/// </summary>
public static async Task<JsonResult> SafeJsonAsync<T>(this Controller controller, Func<Task<T>> dataFactory)
{
var context = controller.HttpContext;

try
{
return controller.Json(await dataFactory());
}
catch (FrontendException exception)
{
LogJsonError(controller, exception);
return controller.Json(new
{
error = exception.Message,
html = exception.HtmlMessages.Select(message => message.Html()),
data = context.IsDevelopmentAndLocalhost(),
});
}
catch (Exception exception)
{
var context = controller.HttpContext;

var logger = context
.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(controller.GetType());
logger.LogError(
exception,
"An error has occurred while generating a JSON result. (Request Route Values: {RouteValues})",
JsonConvert.SerializeObject(context.Request.RouteValues));

LogJsonError(controller, exception);
return controller.Json(context.IsDevelopmentAndLocalhost()
? new { error = exception.Message, data = exception.ToString() }
: new
Expand All @@ -62,4 +67,18 @@ public static async Task<JsonResult> SafeJsonAsync<T>(this Controller controller
});
}
}

private static void LogJsonError(Controller controller, Exception exception)
{
var context = controller.HttpContext;

var logger = context
.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(controller.GetType());
logger.LogError(
exception,
"An error has occurred while generating a JSON result. (Request Route Values: {RouteValues})",
JsonConvert.SerializeObject(context.Request.RouteValues));
}
}
6 changes: 6 additions & 0 deletions Lombiq.HelpfulLibraries.OrchardCore/Shapes/ShapeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,10 @@ public static async Task<IShape> CreateAdHocShapeForCurrentThemeAsync(

return shapeTable.CreateAdHocShape(type, displayAsync);
}

/// <summary>
/// Adds the warning to the screen which says "The current tenant will be reloaded when the settings are saved.".
/// </summary>
public static void AddTenantReloadWarning(this IShape shape) =>
shape.Metadata.Wrappers.Add("Settings_Wrapper__General");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Lombiq.HelpfulLibraries.AspNetCore.Exceptions;
using OrchardCore.DisplayManagement.Notify;
using System.Threading.Tasks;

namespace Lombiq.HelpfulLibraries.OrchardCore.Validation;

public static class NotifierExtensions
{
/// <summary>
/// Emits an error notification for each entry of <see cref="FrontendException.HtmlMessages"/> individually.
/// </summary>
public static async Task FrontEndErrorAsync(this INotifier notifier, FrontendException exception)
{
foreach (var message in exception.HtmlMessages)
{
await notifier.ErrorAsync(message);
}
}
}