-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
19 changed files
with
824 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
86 changes: 86 additions & 0 deletions
86
src/Sagara.Core.AspNetCore/Filters/UnhandledExceptionFilter.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
using System.Globalization; | ||
using System.Text; | ||
using Microsoft.AspNetCore.Http.Extensions; | ||
using Microsoft.AspNetCore.Mvc.Controllers; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Microsoft.AspNetCore.Mvc.RazorPages; | ||
using Microsoft.Extensions.Logging; | ||
|
||
namespace Sagara.Core.AspNetCore.Filters; | ||
|
||
/// <summary> | ||
/// <para>Trap and log exceptions that occur in the MVC pipeline so that we can add more context, e.g., controller, | ||
/// action, and raw URL.</para> | ||
/// <para>The docs say, "Exception filters handle unhandled exceptions, including those that occur during | ||
/// controller creation and model binding. They are only called when an exception occurs in the pipeline. [...] | ||
/// Exception filters are good for trapping exceptions that occur within MVC actions."</para> | ||
/// <para>See: https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/filters#exception-filters</para> | ||
/// </summary> | ||
public class UnhandledExceptionFilter : IExceptionFilter | ||
{ | ||
private readonly ILogger _logger; | ||
|
||
/// <summary> | ||
/// .ctor | ||
/// </summary> | ||
/// <param name="logger"></param> | ||
public UnhandledExceptionFilter(ILogger<UnhandledExceptionFilter> logger) | ||
{ | ||
_logger = logger; | ||
} | ||
|
||
/// <summary> | ||
/// Write an informative, descriptive error message to the configured logger. | ||
/// </summary> | ||
/// <param name="context"></param> | ||
public void OnException(ExceptionContext context) | ||
{ | ||
Check.NotNull(context); | ||
|
||
StringBuilder log = new(); | ||
|
||
if (context.ActionDescriptor is ControllerActionDescriptor cad) | ||
{ | ||
log.AppendLine($"*** Unhandled MVC exception caught in {nameof(UnhandledExceptionFilter)} ***"); | ||
|
||
if (cad.RouteValues.TryGetValue("area", out string? area) && !string.IsNullOrWhiteSpace(area)) | ||
{ | ||
area += "/"; | ||
} | ||
|
||
log.AppendLine(CultureInfo.InvariantCulture, $" Request: {context.HttpContext?.Request?.Method} {area}{cad.ControllerName}/{cad.ActionName}"); | ||
} | ||
else if (context.ActionDescriptor is CompiledPageActionDescriptor cpad) | ||
{ | ||
log.AppendLine($"*** Unhandled Razor Pages exception caught in {nameof(UnhandledExceptionFilter)} ***"); | ||
|
||
// ViewEnginePath already has a / in front, so no need to add one. | ||
cpad.RouteValues.TryGetValue("area", out string? area); | ||
|
||
log.AppendLine(CultureInfo.InvariantCulture, $" Request: {context.HttpContext?.Request?.Method} {area}{cpad.ViewEnginePath}"); | ||
log.AppendLine(CultureInfo.InvariantCulture, $" ViewEnginePath: {cpad.RelativePath}"); | ||
} | ||
else | ||
{ | ||
log.AppendLine($"*** Unhandled exception caught in {nameof(UnhandledExceptionFilter)} ***"); | ||
} | ||
|
||
if (context.HttpContext?.Request is not null) | ||
{ | ||
log.AppendLine(CultureInfo.InvariantCulture, $" Raw URL: {context.HttpContext.Request.GetEncodedUrl()}"); | ||
} | ||
|
||
UnhandledExceptionFilterLogger.UnhandledException(_logger, context.Exception, log.ToString()); | ||
|
||
// Don't set it to handled. Let it continue through the pipeline. | ||
} | ||
} | ||
|
||
/// <summary> | ||
/// High-performance logging for ASP.NET Core. See: https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator | ||
/// </summary> | ||
internal static partial class UnhandledExceptionFilterLogger | ||
{ | ||
[LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "{message}")] | ||
public static partial void UnhandledException(ILogger logger, Exception ex, string message); | ||
} |
51 changes: 51 additions & 0 deletions
51
src/Sagara.Core.AspNetCore/Filters/ValidatorActionFilter.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Sagara.Core.AspNetCore.ModelState; | ||
|
||
namespace Sagara.Core.AspNetCore.Filters; | ||
|
||
/// <summary> | ||
/// <para>Validate non-GET requests from web pages. This is what enables the AJAX-y form submit behavior.</para> | ||
/// </summary> | ||
public class ValidatorActionFilter : IActionFilter | ||
{ | ||
/// <summary> | ||
/// Try to validate the request. | ||
/// </summary> | ||
public void OnActionExecuting(ActionExecutingContext context) | ||
{ | ||
Check.NotNull(context); | ||
|
||
//if (context.HttpContext.Request.IsApiRequest()) | ||
//{ | ||
// // Nothing to do. Don't validate API requests in this action filter. API responses use a separate response | ||
// // model for returning errors. | ||
// return; | ||
//} | ||
|
||
if (context.ModelState.IsValid) | ||
{ | ||
// Nothing to do. Request validation passes. | ||
return; | ||
} | ||
|
||
if (context.HttpContext.Request.Method == "GET") | ||
{ | ||
context.Result = new BadRequestResult(); | ||
return; | ||
} | ||
|
||
|
||
// | ||
// Send the model state error information back to the client as JSON in the body of a 400 Bad Request response. | ||
// | ||
|
||
context.Result = context.ModelState.ToJsonErrorResult(); | ||
} | ||
|
||
/// <summary> | ||
/// Intentionally blank. | ||
/// </summary> | ||
public void OnActionExecuted(ActionExecutedContext context) | ||
{ } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Sagara.Core.AspNetCore.ModelState; | ||
|
||
namespace Sagara.Core.AspNetCore.Filters; | ||
|
||
/// <summary> | ||
/// Check the request's ModelState before the Page gets it. If invalid, return a 400 Bad Request. If | ||
/// it's not a GET, serialize ModelState as JSON and return it in the response body. | ||
/// </summary> | ||
public class ValidatorPageFilter : IPageFilter | ||
{ | ||
/// <summary> | ||
/// Intentionally blank. | ||
/// </summary> | ||
/// <param name="context"></param> | ||
public void OnPageHandlerSelected(PageHandlerSelectedContext context) | ||
{ } | ||
|
||
/// <summary> | ||
/// If model state is valid, do nothing. Otherwise, if this is a GET request, return an empty 400 Bad Request. | ||
/// If this is not a GET request, serialize the ModeState as JSON and return it in the response as a 400 Bad Request. | ||
/// </summary> | ||
/// <param name="context"></param> | ||
public void OnPageHandlerExecuting(PageHandlerExecutingContext context) | ||
{ | ||
Check.NotNull(context); | ||
|
||
if (context.ModelState.IsValid) | ||
{ | ||
// Nothing to do. Request validation passes. | ||
return; | ||
} | ||
|
||
if (context.HttpContext.Request.Method == "GET") | ||
{ | ||
context.Result = new BadRequestResult(); | ||
return; | ||
} | ||
|
||
|
||
// | ||
// Send the model state error information back to the client as JSON in the body of a 400 Bad Request response. | ||
// | ||
|
||
context.Result = context.ModelState.ToJsonErrorResult(); | ||
} | ||
|
||
/// <summary> | ||
/// Intentionally blank. | ||
/// </summary> | ||
/// <param name="context"></param> | ||
public void OnPageHandlerExecuted(PageHandlerExecutedContext context) | ||
{ } | ||
} |
98 changes: 98 additions & 0 deletions
98
src/Sagara.Core.AspNetCore/ModelState/ModelStateDictionaryExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
using System.Net.Mime; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.AspNetCore.Mvc.ModelBinding; | ||
using Sagara.Core.Json.SystemTextJson; | ||
using Sagara.Core.Validation; | ||
|
||
namespace Sagara.Core.AspNetCore.ModelState; | ||
|
||
/// <summary> | ||
/// Extensions to convert ModelStateDictionary errors into JSON responses. | ||
/// </summary> | ||
public static class ModelStateDictionaryExtensions | ||
{ | ||
/// <summary> | ||
/// Creates a 400 Bad Request ContentResult whose body is the JSON-serialized ModelStateDictionary. | ||
/// </summary> | ||
/// <param name="modelState">The ModelState dictionary.</param> | ||
public static ContentResult ToJsonErrorResult(this ModelStateDictionary modelState) | ||
{ | ||
Check.NotNull(modelState); | ||
|
||
return CreateBadRequestJsonContentResult(modelState); | ||
} | ||
|
||
/// <summary> | ||
/// Creates a 400 Bad Request ContentResult whose body is the JSON-serialized ModelStateDictionary. | ||
/// </summary> | ||
/// <param name="modelState">The ModelState dictionary.</param> | ||
/// <param name="additionalErrors">A collection of property-specific error messages to add to model state.</param> | ||
public static ContentResult ToJsonErrorResult(this ModelStateDictionary modelState, IReadOnlyCollection<RequestError> additionalErrors) | ||
{ | ||
Check.NotNull(modelState); | ||
Check.NotNull(additionalErrors); | ||
|
||
foreach (var additionalError in additionalErrors) | ||
{ | ||
modelState.AddModelError(additionalError.PropertyName, additionalError.ErrorMessage); | ||
} | ||
|
||
return CreateBadRequestJsonContentResult(modelState); | ||
} | ||
|
||
/// <summary> | ||
/// Only keep the Errors property, and of the errors, only keep the ErrorMessage. | ||
/// </summary> | ||
public static Dictionary<string, SlimModelStateEntry> ToSlimModelStateDictionary(this ModelStateDictionary modelState) | ||
{ | ||
Check.NotNull(modelState); | ||
|
||
return modelState | ||
.ToDictionary | ||
( | ||
kvp => kvp.Key, | ||
kvp => | ||
{ | ||
var errors = kvp.Value!.Errors | ||
.Select(e => new SlimError(ErrorMessage: e.ErrorMessage)) | ||
.ToArray(); | ||
return new SlimModelStateEntry(errors); | ||
} | ||
); | ||
} | ||
|
||
/// <summary> | ||
/// Add a collection of error messages to ModelState, each with a blank key. | ||
/// </summary> | ||
public static void AddModelErrors(this ModelStateDictionary modelState, IReadOnlyCollection<string> errors) | ||
{ | ||
Check.NotNull(modelState); | ||
Check.NotNull(errors); | ||
|
||
foreach (var error in errors) | ||
{ | ||
modelState.AddModelError(string.Empty, error); | ||
} | ||
} | ||
|
||
|
||
// | ||
// Private methods | ||
// | ||
|
||
private static ContentResult CreateBadRequestJsonContentResult(ModelStateDictionary modelState) | ||
{ | ||
// Since the vast majority of forms on this site use Pascal casing to match variable names, serialize | ||
// model state in Pascal case so that we can work with the client side variable names. | ||
var content = STJsonHelper.Serialize(modelState.ToSlimModelStateDictionary(), camelCase: false); | ||
|
||
return new ContentResult | ||
{ | ||
Content = content, | ||
ContentType = MediaTypeNames.Application.Json, | ||
StatusCode = StatusCodes.Status400BadRequest, | ||
}; | ||
} | ||
} |
29 changes: 29 additions & 0 deletions
29
src/Sagara.Core.AspNetCore/ModelState/SlimModelStateEntry.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
namespace Sagara.Core.AspNetCore.ModelState; | ||
|
||
/// <summary> | ||
/// For sending model state errors back to the browser without all the extra, unnecessary fluff. | ||
/// </summary> | ||
public record SlimModelStateEntry | ||
{ | ||
/// <summary> | ||
/// The collection of model state errors for the request. | ||
/// </summary> | ||
public IReadOnlyCollection<SlimError> Errors { get; init; } | ||
|
||
/// <summary> | ||
/// .ctor | ||
/// </summary> | ||
/// <param name="errors"></param> | ||
public SlimModelStateEntry(IReadOnlyCollection<SlimError> errors) | ||
{ | ||
Check.NotNull(errors); | ||
|
||
Errors = errors; | ||
} | ||
} | ||
|
||
/// <summary> | ||
/// Used to reduce the content of a model state dictionary that's sent back to the browser. | ||
/// </summary> | ||
/// <param name="ErrorMessage">The error message for the request.</param> | ||
public readonly record struct SlimError(string ErrorMessage); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Sagara.Core.AspNetCore | ||
|
||
Common ASP.NET Core code that I use across many projects. | ||
|
||
See the [GitHub repo](https://github.com/jonsagara/Sagara.Core) for documentation. |
Oops, something went wrong.