Skip to content

Commit

Permalink
Added Sagara.Core.AspNetCore.
Browse files Browse the repository at this point in the history
  • Loading branch information
jonsagara committed Sep 24, 2023
1 parent 33e9cc9 commit ca74043
Show file tree
Hide file tree
Showing 19 changed files with 824 additions and 1 deletion.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ proof-of-concept or bug demonstration applications. It reduces the amount of boi
[Sagara.Core docs](src/Sagara.Core/docs/index.md)


## Sagara.Core.AspNetCore

[![NuGet Sagara.Core.AspNetCore](https://buildstats.info/nuget/Sagara.Core.AspNetCore)](https://www.nuget.org/packages/Sagara.Core.AspNetCore)

[Sagara.Core.AspNetCore docs](src/Sagara.Core.AspNetCore/docs/index.md)


## Sagara.Core.Caching

[![NuGet Sagara.Core.Caching](https://buildstats.info/nuget/Sagara.Core.Caching)](https://www.nuget.org/packages/Sagara.Core.Caching)
Expand Down
6 changes: 6 additions & 0 deletions Sagara.Core.sln
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sagara.Core.Caching", "src\
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sagara.Core.Logging.Serilog", "src\Sagara.Core.Logging.Serilog\Sagara.Core.Logging.Serilog.csproj", "{D2762B2D-1CD6-4FAC-BF28-FDA752F029BC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sagara.Core.AspNetCore", "src\Sagara.Core.AspNetCore\Sagara.Core.AspNetCore.csproj", "{B14FE614-00CE-497B-BD0E-5A20C3C68343}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -37,6 +39,10 @@ Global
{D2762B2D-1CD6-4FAC-BF28-FDA752F029BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2762B2D-1CD6-4FAC-BF28-FDA752F029BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2762B2D-1CD6-4FAC-BF28-FDA752F029BC}.Release|Any CPU.Build.0 = Release|Any CPU
{B14FE614-00CE-497B-BD0E-5A20C3C68343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B14FE614-00CE-497B-BD0E-5A20C3C68343}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B14FE614-00CE-497B-BD0E-5A20C3C68343}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B14FE614-00CE-497B-BD0E-5A20C3C68343}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
11 changes: 11 additions & 0 deletions make_docs.bat
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ REM Mad props to DefaultDocumentation, which we have installed as a local tool.
REM See: https://github.com/Doraku/DefaultDocumentation

REM Sagara.Core
echo [Sagara.Core] Generating docs...
dotnet defaultDocumentation -a "src\Sagara.Core\bin\Debug\net8.0\Sagara.Core.dll" -o "src\Sagara.Core\docs" --ConfigurationFilePath ".\DefaultDocumentation.json"
echo [Sagara.Core] Done.

REM Sagara.Core.AspNetCore
echo [Sagara.Core.AspNetCore] Generating docs...
dotnet defaultDocumentation -a "src\Sagara.Core.AspNetCore\bin\Debug\net8.0\Sagara.Core.AspNetCore.dll" -o "src\Sagara.Core.AspNetCore\docs" --ConfigurationFilePath ".\DefaultDocumentation.json"
echo [Sagara.Core.AspNetCore] Done.

REM Sagara.Core.Caching
echo [Sagara.Core.Caching] Generating docs...
dotnet defaultDocumentation -a "src\Sagara.Core.Caching\bin\Debug\net8.0\Sagara.Core.Caching.dll" -o "src\Sagara.Core.Caching\docs" --ConfigurationFilePath ".\DefaultDocumentation.json"
echo [Sagara.Core.Caching] Done.

REM Sagara.Core.Logging.Serilog
echo [Sagara.Core.Logging.Serilog] Generating docs...
dotnet defaultDocumentation -a "src\Sagara.Core.Logging.Serilog\bin\Debug\net8.0\Sagara.Core.Logging.Serilog.dll" -o "src\Sagara.Core.Logging.Serilog\docs" --ConfigurationFilePath ".\DefaultDocumentation.json"
echo [Sagara.Core.Logging.Serilog] Done.
86 changes: 86 additions & 0 deletions src/Sagara.Core.AspNetCore/Filters/UnhandledExceptionFilter.cs
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, &quot;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.&quot;</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 src/Sagara.Core.AspNetCore/Filters/ValidatorActionFilter.cs
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)
{ }
}
55 changes: 55 additions & 0 deletions src/Sagara.Core.AspNetCore/Filters/ValidatorPageFilter.cs
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)
{ }
}
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 src/Sagara.Core.AspNetCore/ModelState/SlimModelStateEntry.cs
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);
5 changes: 5 additions & 0 deletions src/Sagara.Core.AspNetCore/README.md
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.
Loading

0 comments on commit ca74043

Please sign in to comment.