Skip to content

Commit

Permalink
feature(#86): Add Blazor Component Rendering Service
Browse files Browse the repository at this point in the history
  • Loading branch information
TwentyFourMinutes authored and jhartmann123 committed Jun 13, 2024
1 parent f1802a6 commit bf00590
Show file tree
Hide file tree
Showing 35 changed files with 1,589 additions and 40 deletions.
40 changes: 36 additions & 4 deletions docs/Email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

- [Email](#email)
- [Setup](#setup)
- [Create and send an email](#create-and-send-an-email)
- [View locations](#view-locations)
- [Razor Views (MVC)](#razor-views-mvc)
- [Create and send an email](#create-and-send-an-email)
- [View locations](#view-locations)
- [Set a Subject](#set-a-subject)
- [Razor Components (Blazor)](#razor-components-blazor)
- [Create and send an email](#create-and-send-an-email-1)
- [Component locations](#component-locations)
- [Set a Subject](#set-a-subject-1)
- [Attachments](#attachments)
- [Headers](#headers)

Expand All @@ -16,7 +22,9 @@ The settings contain sender Email and name, SMTP settings, Debug settings and CS
container.RegisterEmail(options => Configuration.GetSection("Email").Bind(options));
```

## Create and send an email
## Razor Views (MVC)

### Create and send an email

To add new emails, following steps need to be done:

Expand All @@ -28,7 +36,7 @@ To add new emails, following steps need to be done:

To check the visuals of the view file, use the [Swagger API](./api.md) to access the methods of the `EmailController`.

## View locations
### View locations

The views that are used for the emails are configured on the `EmailViewAttribute`. To find the view, the Razor view engine looks in the folder `Views` by default. A view path of `Emails/FancyEmail` matches to `Views/Emails/FancyEmail.cshtml`.

Expand All @@ -38,6 +46,30 @@ If you want to place the Views in other folders, for example `/Emails`, you can
services.Configure<RazorViewEngineOptions>(options => options.ViewLocationFormats.Add("/Emails/{0}" + RazorViewEngine.ViewExtension));
```

### Set a Subject
Per default the SubjectKey is "Subject" and it is being tried to resolve from the corresponding resource file. If you want to use a different key, you can set it in the `EmailViewAttribute` like this: `[EmailView("Emails/Registration", SubjectKey = "RegistrationSubject")]`. If no value could be found it will fallback to the the SubjectKey.

## Razor Components (Blazor)

### Create and send an email

To add new emails, following steps need to be done:

1) Add a new Component (`.razor`) in the directory `Components/Emails/`.
2) Add a new ComponentModel in the same namespace as the business logic that needs to send the mail.
3) Implement the `IComponentModel<T>` interface in the ComponentModel, where `T` is the type of the Component.
4) Extend the `EmailController` with a new method to render the razor file and return the contents.
5) To send the mail in the business logic use the MediatR-command `SendEmail` and supply it with the view model. SendEmail renders the mail based on the component model and the `IComponentModel<T>`.

To check the visuals of the view file, use the [Swagger API](./api.md) to access the methods of the `EmailController`.

### Component locations

In contrast to the classic Razor Views, the Razor Components can be placed in any folder, even in a different assembly, as long as you can reference it in the `IComponentModel<T>`.

### Set a Subject
Per default the SubjectKey is "Subject" and it is being tried to resolve from the corresponding resource file. If you want to use a different key, you can optionally implement the `IProvideEmailSubject` interface like this `string IProvideEmailSubject.SubjectKey => "RegistrationSubject";`. If no value could be found it will fallback to the the SubjectKey.

## Attachments

By default, only attachments in file://-Uris are supported. To allow adding attachments from other sources (eg. AWS S3), implement `IEmailAttachmentResolver` and register it SimpleInjector with
Expand Down
50 changes: 50 additions & 0 deletions src/AspNetCore/src/Blazor/BlazorRenderingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Globalization;
using System.Reflection;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;

namespace Fusonic.Extensions.AspNetCore.Blazor;

public class BlazorRenderingService(HtmlRenderer htmlRenderer) : IBlazorRenderingService
{
public async Task<string> RenderComponent(IComponentModel model, CultureInfo culture)
{
var modelType = model.GetType();
var componentType = model.ComponentType;

var modelProperty = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.FirstOrDefault(p => p.PropertyType == modelType)
?? throw new ArgumentNullException($"The Component {componentType.Name} is missing a property of type {modelType.Name}.");

return await RenderComponent(componentType, culture, new() { [modelProperty.Name] = model }, beforeRender: null);
}

public Task<string> RenderComponent<T>(CultureInfo culture, Dictionary<string, object?>? dictionary = null) where T : IComponent
=> RenderComponentBase(typeof(T), culture, dictionary is null ? ParameterView.Empty : ParameterView.FromDictionary(dictionary), beforeRender: null);

public Task<string> RenderComponent(Type componentType, CultureInfo culture, Dictionary<string, object?>? dictionary = null, Action? beforeRender = null)
=> RenderComponentBase(componentType, culture, dictionary is null ? ParameterView.Empty : ParameterView.FromDictionary(dictionary), beforeRender);

private async Task<string> RenderComponentBase(Type componentType, CultureInfo culture, ParameterView parameters, Action? beforeRender)
=> await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var currentCulture = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
try
{
// The razor renderer takes the culture from the current thread culture.
CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = culture;

beforeRender?.Invoke();

var output = await htmlRenderer.RenderComponentAsync(componentType, parameters);
return output.ToHtmlString();
}
finally
{
(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture) = currentCulture;
}
});
}
14 changes: 14 additions & 0 deletions src/AspNetCore/src/Blazor/IBlazorRenderingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Globalization;
using Microsoft.AspNetCore.Components;

namespace Fusonic.Extensions.AspNetCore.Blazor;

public interface IBlazorRenderingService
{
Task<string> RenderComponent(IComponentModel model, CultureInfo culture);
Task<string> RenderComponent<T>(CultureInfo culture, Dictionary<string, object?>? dictionary = null) where T : IComponent;
Task<string> RenderComponent(Type componentType, CultureInfo culture, Dictionary<string, object?>? dictionary = null, Action? beforeRender = null);
}
11 changes: 11 additions & 0 deletions src/AspNetCore/src/Blazor/IComponentModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;

public interface IComponentModel<T> : IComponentModel where T : IComponent
{
Type IComponentModel.ComponentType => typeof(T);
}

public interface IComponentModel
{
Type ComponentType { get; }
}
1 change: 0 additions & 1 deletion src/AspNetCore/src/Razor/RazorViewModelAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ namespace Fusonic.Extensions.AspNetCore.Razor;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class RazorViewModelAttribute(string viewPath) : Attribute
{

/// <summary>
/// Path to the view within the razor search paths.
/// Example: A value of "Feature/FancyPage" usually finds the View "Views/Feature/FancyPage.cshtml"
Expand Down
69 changes: 69 additions & 0 deletions src/AspNetCore/test/Blazor/BlazorRenderingServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Globalization;
using System.Text.RegularExpressions;
using System.Web;
using FluentAssertions;
using Fusonic.Extensions.AspNetCore.Blazor;
using Xunit;

namespace Fusonic.Extensions.AspNetCore.Tests.Blazor;

public partial class BlazorRenderingServiceTests : TestBase
{
private readonly BlazorRenderingService blazorRenderingService;

public BlazorRenderingServiceTests(TestFixture fixture) : base(fixture)
{
blazorRenderingService = GetInstance<BlazorRenderingService>();
}

[Fact]
public async Task Succeeds()
{
// Arrange
var component = new TestComponent.ComponentModel("Hello World!");

// Act
var html = await blazorRenderingService.RenderComponent(component, CultureInfo.CurrentCulture);

// Assert
html.Should().Be("<body>Hello World!</body>");
}

[Theory]
[InlineData("de-CH")]
[InlineData("de-AT")]
[InlineData("fr-CH")]
[InlineData("it-CH")]
[InlineData("en-CH")]
public async Task Rendering_RespectsCulture(string cultureName)
{
// Arrange
var cultureInfo = new CultureInfo(cultureName);

var viewModel = new TestCultureComponent.ComponentModel(new(2019, 1, 9), 12345678.89);

var expectedDate = AsString($"{viewModel.Date:d}");
var expectedMonth = AsString($"{viewModel.Date:MMMM}");
var expectedNumber = AsString($"{viewModel.Number:n}");

// Act
var body = await blazorRenderingService.RenderComponent(viewModel, cultureInfo);

// Assert
var regex = GetHtmlParagraphContentsRegex();
var matches = regex.Matches(body);

Match(0).Should().Be(expectedDate);
Match(1).Should().Be(expectedMonth);
Match(2).Should().Be(expectedNumber);

string Match(int idx) => HttpUtility.HtmlDecode(matches[idx].Groups[1].Value);
string AsString(IFormattable f) => f.ToString(null, cultureInfo);
}

[GeneratedRegex("<p>(.*?)<\\/p>")]
private static partial Regex GetHtmlParagraphContentsRegex();
}
10 changes: 10 additions & 0 deletions src/AspNetCore/test/Blazor/TestComponent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@using Fusonic.Extensions.AspNetCore.Razor

<body>@Model.Name</body>

@code {
[Parameter]
public required ComponentModel Model { get; init; }

public record ComponentModel(string Name) : IComponentModel<TestComponent>;
}
19 changes: 19 additions & 0 deletions src/AspNetCore/test/Blazor/TestCultureComponent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@using Fusonic.Extensions.AspNetCore.Razor

<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<p>@($"{Model.Date:d}")</p>
<p>@($"{Model.Date:MMMM}")</p>
<p>@($"{Model.Number:N}")</p>
</body>
</html>

@code {
[Parameter]
public required ComponentModel Model { get; init; }

public record ComponentModel(DateTime Date, double Number) : IComponentModel<TestCultureComponent>;
}
9 changes: 9 additions & 0 deletions src/AspNetCore/test/TestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@

using System.Diagnostics;
using System.Reflection;
using Fusonic.Extensions.AspNetCore.Blazor;
using Fusonic.Extensions.AspNetCore.Razor;
using Fusonic.Extensions.UnitTests.SimpleInjector;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleInjector;

Expand All @@ -20,13 +23,19 @@ protected sealed override void RegisterCoreDependencies(Container container)
base.RegisterCoreDependencies(container);

container.Register<RazorViewRenderingService>();
container.Register<BlazorRenderingService>();

var services = new ServiceCollection();

services.AddRazorComponents();
services.AddRazorPages()
.ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(new CompiledRazorAssemblyPart(Assembly.GetExecutingAssembly())));

services.AddLogging();
services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddSingleton<IConfiguration>(_ => new ConfigurationBuilder().Build());
services.AddScoped<HtmlRenderer>();

services.AddScoped<IViewLocalizer, ViewLocalizer>();
services.AddScoped<IHtmlLocalizerFactory, HtmlLocalizerFactory>();
services.AddSingleton<IWebHostEnvironment, TestWebHostEnvironment>();
Expand Down
59 changes: 59 additions & 0 deletions src/Email/src/BlazorEmailRenderingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Fusonic GmbH. All rights reserved.
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Globalization;
using System.Reflection;
using Fusonic.Extensions.AspNetCore.Blazor;
using Microsoft.Extensions.Localization;

namespace Fusonic.Extensions.Email;

public class BlazorEmailRenderingService(IBlazorRenderingService blazorRenderingService, IStringLocalizerFactory stringLocalizerFactory) : IEmailRenderingService
{
private const string DefaultSubjectKey = "Subject";

public bool Supports(object model) => model is IComponentModel;

/// <inheritdoc />
public async Task<(string Subject, string Body)> RenderAsync(
object model,
CultureInfo culture,
string? subjectKey,
object[]? subjectFormatParameters = null)
{
var modelType = model.GetType();

if (model is not IComponentModel componentModel)
throw new ArgumentException($"The type {modelType.Name} does not implement {nameof(IComponentModel)}.", nameof(model));

var componentType = componentModel.ComponentType;
var modelProperty = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.FirstOrDefault(p => p.PropertyType == modelType)
?? throw new ArgumentNullException(nameof(model), $"The Component {componentType.Name} is missing a property of type {modelType.Name}.");

subjectKey ??= (componentModel as IProvideEmailSubject)?.SubjectKey ?? DefaultSubjectKey;
var subject = subjectKey;

var body = await RenderAsync(
componentType,
modelProperty.Name,
componentModel,
culture,
beforeRender: SetSubject);

return (subject, body);

void SetSubject()
{
var subjectLocalization = stringLocalizerFactory.Create(componentType).GetString(subjectKey, subjectFormatParameters ?? []);
subject = subjectLocalization.ResourceNotFound ? subjectKey : subjectLocalization.Value;
}
}

private async Task<string> RenderAsync(Type componentType, string modelPropertyName, IComponentModel model, CultureInfo culture, Action beforeRender)
{
var content = await blazorRenderingService.RenderComponent(componentType, culture, new() { [modelPropertyName] = model }, beforeRender);
content = CssInliner.Inline(content);
return content;
}
}
12 changes: 10 additions & 2 deletions src/Email/src/ContainerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// Licensed under the MIT License. See LICENSE file in the project root for license information.

using System.Reflection;
using Fusonic.Extensions.AspNetCore.Blazor;
using Fusonic.Extensions.AspNetCore.Razor;
using Fusonic.Extensions.Mediator;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Localization;
using SimpleInjector;

namespace Fusonic.Extensions.Email;
Expand Down Expand Up @@ -32,9 +34,15 @@ public static void RegisterEmail(this Container container, Action<EmailOptions>
container.RegisterInstance(options);

container.RegisterInstance(new CssInliner(options));
container.Register<IEmailRenderingService, RazorEmailRenderingService>();
container.Register<IRazorViewRenderingService, RazorViewRenderingService>();

container.RegisterInstance(container.GetInstance<IViewLocalizer>);
container.RegisterInstance(container.GetInstance<IStringLocalizerFactory>);

container.Register<IRazorViewRenderingService, RazorViewRenderingService>();
container.Register<IBlazorRenderingService, BlazorRenderingService>();

container.Collection.Append<IEmailRenderingService, RazorEmailRenderingService>();
container.Collection.Append<IEmailRenderingService, BlazorEmailRenderingService>();

container.Register<ISmtpClient, SmtpClient>();
container.Collection.Append<IEmailAttachmentResolver, FileAttachmentResolver>(Lifestyle.Singleton);
Expand Down
Loading

0 comments on commit bf00590

Please sign in to comment.