Skip to content

Commit

Permalink
Merge pull request #300 from Lombiq/issue/OFFI-149
Browse files Browse the repository at this point in the history
OFFI-149: HttpContent and ZipArchive extensions, UserReadableException
  • Loading branch information
wAsnk authored Dec 4, 2024
2 parents a5492a6 + 51a592f commit e1558f8
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 9 deletions.
1 change: 1 addition & 0 deletions Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- `DateTimeHttpContextExtensions`: Makes it possible to set or get IANA time-zone IDs in the HTTP context.
- `EnvironmentHttpContextExtensions`: Provides shortcuts to determine information about the current hosting environment, like whether the app is running in Development mode.
- `ForwardedHeadersApplicationBuilderExtensions`: Provides `UseForwardedHeadersForCloudflareAndAzure()` that forwards proxied headers onto the current request with settings suitable for an app behind Cloudflare and hosted in an Azure App Service.
- `HttpContentExtensions`: Extensions for `HttpContent` types, which are used as message body when sending requests via `HttpClient`.
- `JsonStringExtensions`: Adds JSON related extensions for the `string` type. For example, `JsonHtmlContent` safely serializes a string for use in `<script>` elements.
- `NonEmptyTagHelper`: An attribute tag helper that conditionally hides its element if the provided collection is null or empty. This eliminates a bulky wrapping `@if(collection?.Count > 1) { ... }` expression that would needlessly increase the document's indentation too.
- `TemporaryResponseWrapper`: An `IAsyncDisposable` that replaces the `HttpContext`'s response stream at creation with a `MemoryStream` and copies its content back into the real response stream during disposal.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public FrontendException(LocalizedHtmlString message, Exception? innerException
HtmlMessages = [message];

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

public FrontendException(string message)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.StaticFiles;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;

namespace System.Net.Http;

public static class HttpContentExtensions
{
/// <summary>
/// Attaches a new file field to this web request content.
/// </summary>
/// <param name="form">The form content of the request.</param>
/// <param name="name">The name of the field.</param>
/// <param name="fileName">The name and extension of the file being uploaded.</param>
/// <param name="mediaType">The file's MIME type (use <see cref="MediaTypeNames"/>).</param>
/// <param name="content">The content of the file.</param>
[SuppressMessage(
"Reliability",
"CA2000:Dispose objects before losing scope",
Justification = "The parent form should be disposed instead.")]
public static void AddFile(
this MultipartFormDataContent form,
string name,
string fileName,
string mediaType,
byte[] content)
{
var xml = new ByteArrayContent(content);
xml.Headers.ContentType = MediaTypeHeaderValue.Parse(mediaType);
form.Add(xml, name, fileName);
}

/// <inheritdoc cref="AddFile(System.Net.Http.MultipartFormDataContent,string,string,string,byte[])"/>
/// <param name="content">The content of the file. It will be encoded as UTF-8.</param>
public static void AddFile(
this MultipartFormDataContent form,
string name,
string fileName,
string mediaType,
string content) =>
form.AddFile(name, fileName, mediaType, Encoding.UTF8.GetBytes(content));

/// <summary>
/// Adds a file from disk. The file name is derived from <paramref name="path"/> and if <paramref name="mediaType"/>
/// is <see langword="null"/>, then it's guessed from the file name as well.
/// </summary>
public static void AddLocalFile(
this MultipartFormDataContent form,
string name,
string path,
string mediaType = null)
{
if (string.IsNullOrEmpty(mediaType) &&
!new FileExtensionContentTypeProvider().TryGetContentType(path, out mediaType))
{
// Fall back to a media type that indicates unspecified binary data.
mediaType = MediaTypeNames.Application.Octet;
}

form.AddFile(name, Path.GetFileName(path), mediaType, File.ReadAllBytes(path));
}
}
3 changes: 3 additions & 0 deletions Lombiq.HelpfulLibraries.Common/Docs/Exceptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Lombiq Helpful Libraries - Common - Exceptions

- `UserReadableException`: An exception whose message is safe to display to the end user.
55 changes: 55 additions & 0 deletions Lombiq.HelpfulLibraries.Common/Exceptions/UserReadableException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;

namespace Lombiq.HelpfulLibraries.Common.Exceptions;

/// <summary>
/// An exception whose message is safe to display to the end user of a desktop or command line application.
/// </summary>
/// <remarks><para>
/// In case of web application, use <c>Lombiq.HelpfulLibraries.AspNetCore</c>'s <c>FrontendException</c> instead.
/// </para></remarks>
public class UserReadableException : Exception
{
/// <summary>
/// Gets the list of error messages that can be displayed to the user.
/// </summary>
public IReadOnlyList<string> Messages { get; } = [];

public UserReadableException(ICollection<string> messages, Exception? innerException = null)
: base(string.Join(Environment.NewLine, messages), innerException) =>
Messages = [.. messages];

public UserReadableException(string message)
: this([message])
{
}

public UserReadableException()
{
}

public UserReadableException(string message, Exception? innerException)
: this([message], innerException)
{
}

/// <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="UserReadableException">The non-empty error messages from <paramref name="errors"/>.</exception>
public static void ThrowIfAny(ICollection<string>? errors)
{
errors = errors?.WhereNot(string.IsNullOrWhiteSpace).ToList();

if (errors == null || errors.Count == 0) return;
if (errors.Count == 1) throw new UserReadableException(errors.Single());

throw new UserReadableException(errors);
}
}
35 changes: 35 additions & 0 deletions Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace System.IO.Compression;

public static class ZipArchiveExtensions
{
/// <summary>
/// Creates a new text file in <paramref name="zip"/> and writes the <paramref name="lines"/> into it.
/// </summary>
public static async Task CreateTextEntryAsync(this ZipArchive zip, string entryName, IEnumerable<string> lines)
{
await using var writer = new StreamWriter(zip.CreateEntry(entryName).Open());

foreach (var line in lines)
{
await writer.WriteLineAsync(line);
}
}

/// <summary>
/// Creates a new text file in <paramref name="zip"/> and writes the <paramref name="text"/> into it.
/// </summary>
public static Task CreateTextEntryAsync(this ZipArchive zip, string entryName, string text) =>
zip.CreateTextEntryAsync(entryName, [text]);

/// <summary>
/// Creates a new binary file in <paramref name="zip"/> and writes the <paramref name="data"/> into it.
/// </summary>
public static async Task CreateBinaryEntryAsync(this ZipArchive zip, string entryName, ReadOnlyMemory<byte> data)
{
await using var stream = zip.CreateEntry(entryName).Open();
await stream.WriteAsync(data);
}
}
1 change: 1 addition & 0 deletions Lombiq.HelpfulLibraries.Common/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ For general details about and on using the Helpful Libraries see the [root Readm
## Documentation

- [Dependency Injection](Docs/DependencyInjection.md)
- [Exceptions](Docs/Exceptions.md)
- [Extensions](Docs/Extensions.md)
- [Utilities](Docs/Utilities.md)
- [Validation](Docs/Validation.md)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Lombiq.HelpfulLibraries.AspNetCore.Exceptions;
using Lombiq.HelpfulLibraries.Common.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Routing;
Expand All @@ -8,6 +9,7 @@
using OrchardCore.ContentManagement;
using System;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;

Expand Down Expand Up @@ -50,6 +52,16 @@ public static async Task<JsonResult> SafeJsonAsync<T>(this Controller controller
{
return controller.Json(await dataFactory());
}
catch (UserReadableException exception)
{
LogJsonError(controller, exception);
return controller.Json(new
{
error = exception.Message,
html = exception.Messages.Select(HtmlEncoder.Default.Encode),
data = context.IsDevelopmentAndLocalhost(),
});
}
catch (FrontendException exception)
{
LogJsonError(controller, exception);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using Microsoft.AspNetCore.Http;
using Lombiq.HelpfulLibraries.AspNetCore.Exceptions;
using Lombiq.HelpfulLibraries.Common.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OrchardCore.Security.Permissions;
using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -66,17 +70,26 @@ public static async Task<IActionResult> AuthorizeForCurrentUserValidateAndExecut
}
}

var (isSuccess, data) = validateAsync is null ? (true, default) : await validateAsync();
if (!isSuccess) return controller.NotFound();
try
{
var (isSuccess, data) = validateAsync is null ? (true, default) : await validateAsync();
if (!isSuccess) return controller.NotFound();

var result = await executeAsync(data);

var result = await executeAsync(data);
if (checkModelState && !controller.ModelState.IsValid)
{
return controller.ValidationProblem(controller.ModelState);
}

if (checkModelState && !controller.ModelState.IsValid)
return result as IActionResult ?? controller.Ok(result);
}
catch (Exception exception) when (exception is UserReadableException or FrontendException)
{
return controller.ValidationProblem(controller.ModelState);
var logger = controller.HttpContext?.RequestServices?.GetService<ILogger<Controller>>();
logger?.LogError(exception, "An error has occurred.");
return controller.BadRequest(exception.Message);
}

return result is IActionResult actionResult ? actionResult : controller.Ok(result);
}

/// <inheritdoc cref="AuthorizeForCurrentUserValidateAndExecuteAsync{TData,TResult}"/>
Expand Down

0 comments on commit e1558f8

Please sign in to comment.