ASP.NET Core : Envoi de mails avec Razor

Photo rédacteur
Par
Le
Mis à jour le

Il était possible en ASP.NET MVC 5 d'utiliser les classes RazorEngineHost, RazorTemplateEngine et CSharpRazorCodeLanguage pour récupérer le HTML en sortie d'un template Razor. C'est également possible en ASP.NET Core, mais la classe CSharpRazorCodeLanguage n'existant pas (pour le moment?) dans le framework .NET Core, la technique est un peu différente.

Il existe d'autres moteurs de templates qui fonctionnent très bien, tel que {{ mustache }}. Wikipedia dispose d'ailleurs d'un comparatif complet. Mais l'intérêt dans le cas présent, est de réutiliser les Views et les Models / ViewModels de votre projet ASP.NET Core.

L'article a été développé avec les versions suivantes :

- ASP.NET Core RC2
- Runtime CoreCLR 1.0.0-rc2-16496

Lire l'article suivant pour l'installation des dernières versions des runtimes / packages.

Récupération de la vue

L'interface Microsoft.AspNetCore.Mvc.ViewEngines.IViewEngine contient une méthode FindView qui permet de récupérer un objet ViewEngineResult, contenant lui même la vue de type Microsoft.AspNetCore.Mvc.ViewEngines.IView.

Sur cette vue, il est ensuite possible d'appeler la méthode qui nous intéresse : RenderAsync(), qui retourne le résultat de l'exécution du template, sous forme de chaîne de caractères.

La méthode FindView() prend en paramètre un objet de type ActionContext. Cet objet correspond au contexte d'une requête HTTP. Il est facile de le récupérer depuis un controlleur, via la propriété Controller.ActionContext. Mais comment faire lorsque l'application ne tourne pas sur un serveur HTTP, par exemple dans un projet de type batch ? Il faut pouvoir "mocker" les classes auxquelles nous n'avons pas accès en dehors d'un projet web.

Configuration des services

Pour récupérer la sortie HTML d'une vue Razor quel que soit le type d'application, il faut créer un nouveau container de services Microsoft.Extensions.DependencyInjection.ServiceCollection. La configuration minimale pour utiliser les classes MVC est la suivante :

var services = new ServiceCollection();
var applicationEnvironment = PlatformServices.Default.Application;
var diagnosticSource = new DiagnosticListener("Microsoft.AspNetCore");
services
    .AddSingleton<IHostingEnvironment>(new HostingEnvironment())
    .AddSingleton(applicationEnvironment)
    .AddSingleton<DiagnosticSource>(diagnosticSource)
    .AddSingleton<FakeHttpContext>()
    .AddSingleton<RazorViewGenerator>()
    .AddSingleton<ILocStringViewContext, FakeLocStringViewContext>();
services.AddLogging().AddMvc();

Pour utiliser les classes MVC, le container des services doit enregistrer une instance qui hérite de Microsoft.AspNetCore.Http.HttpContext. Notre classe FakeHttpContext est une implémentation qui permet d'utiliser un faux contexte HTTP.

public class FakeHttpContext : DefaultHttpContext
{
    public FakeHttpContext(IServiceProvider serviceProvider)
    {
        this.RequestServices = serviceProvider;
    }
}

Avec cette implémentation, il ne sera pas possible d'utiliser des données provenants du contexte HTTP dans les vues. Libre à vous cependant de modifier cette classe pour correspondre à votre besoin.

Récupération de la sortie

L'idée est maintenant de faire de l'Inversion of Control sur le container de services préalablement créé pour récupérer les instances dont nous avons besoin, à savoir : IRazorViewEngine, ITempDataProvider et FakeHttpContext. Ces objets permettent ensuite de créer les autres objets dont nous avons besoin.

Le ActionContext :

var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());

Le ViewDataDictionary, où model est la classe utilisée comme modèle par la vue :

var viewDataDictionnary = new ViewDataDictionary(
    metadataProvider: new EmptyModelMetadataProvider(),
    modelState: new ModelStateDictionary())
    {
        Model = model
    };

Et enfin, le ViewContext :

using (var output = new StringWriter())
{
    var viewContext = new ViewContext(
        actionContext,
        view,
        viewDataDictionnary,
        new TempDataDictionary(actionContext.HttpContext, tempDataProvider),
        output,
        new HtmlHelperOptions());
    await view.RenderAsync(viewContext);
    return output.ToString();
}

Code complet

Nous pouvons créer une classe autonome RazorViewGenerator, qui se charge de créer un container de services et de la paramétrer correctement. Une méthode RenderViewToString() prend en paramètre le nom de la vue ainsi que le modèle qui lui est affecté. Et le tour est joué. Le contenu HTML peut ensuite être utilisé pour envoyer des mails, ou pour tout autre usage.

Attention : le nom de la vue ne doit pas contenir l'extension .cshtml, et les fichiers doivent obligatoirement se situer dans un répertoire nommé 'Views'.

La classe ci-dessous permet également de spécifier le chemin, qui doit être absolu, dans lequel se trouvent les vues. Il s'agit par défaut du répertoire de l'application.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Internal;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.PlatformAbstractions;
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace HashCode.Common.AspNetCore
{
    public class RazorViewGenerator
    {
        private readonly IRazorViewEngine viewEngine;
        private readonly ITempDataProvider tempDataProvider;
        private readonly ActionContext actionContext;

        public static RazorViewGenerator Create(string viewsBasePath = null)
        {
            var services = new ServiceCollection();
            var applicationEnvironment = PlatformServices.Default.Application;
            var diagnosticSource = new DiagnosticListener("Microsoft.AspNetCore");
            var basePath = viewsBasePath ?? applicationEnvironment.ApplicationBasePath;
            services
                .AddSingleton<IHostingEnvironment>(new HostingEnvironment())
                .AddSingleton(applicationEnvironment)
                .AddSingleton<DiagnosticSource>(diagnosticSource)
                .AddSingleton<FakeHttpContext>()
                .AddSingleton<RazorViewGenerator>()
                .AddSingleton<ILocStringViewContext, FakeLocStringViewContext>();
            services.Configure<RazorViewEngineOptions>(options =>
            {
                options.FileProviders.Clear();
                options.FileProviders.Add(new PhysicalFileProvider(basePath));
            });
            services.AddLogging().AddMvc();
            var servicesProvider = services.BuildServiceProvider();
            var instance = servicesProvider.GetRequiredService<RazorViewGenerator>();
            return instance;
        }

        public RazorViewGenerator(
            IRazorViewEngine viewEngine,
            ITempDataProvider tempDataProvider,
            FakeHttpContext httpContext)
        {
            this.viewEngine = viewEngine;
            this.tempDataProvider = tempDataProvider;
            this.actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
        }

        public async Task<string> RenderViewToString(string name, object model)
        {
            var viewEngineResult = viewEngine.FindView(this.actionContext, name, false);
            if (viewEngineResult.Success == false)
            {
                throw new InvalidOperationException($"Couldn't find view '{name}'. Searched locations: " + string.Join(", ", viewEngineResult.SearchedLocations));
            }
            var view = viewEngineResult.View;
            var viewDataDictionnary = new ViewDataDictionary(
                metadataProvider: new EmptyModelMetadataProvider(),
                modelState: new ModelStateDictionary())
            {
                Model = model
            };
            using (var output = new StringWriter())
            {
                var viewContext = new ViewContext(
                    actionContext,
                    view,
                    viewDataDictionnary,
                    new TempDataDictionary(actionContext.HttpContext, tempDataProvider),
                    output,
                    new HtmlHelperOptions());
                await view.RenderAsync(viewContext);
                return output.ToString();
            }
        }

        public class FakeHttpContext : DefaultHttpContext
        {
            public FakeHttpContext(IServiceProvider serviceProvider)
            {
                this.RequestServices = serviceProvider;
            }
        }
    }
}

Projet complet sur Github

Vous pouvez trouver le projet complet et un exemple sur notre repository github :

GenerateStringFromRazorView

Résultat programme github
aspnetcore mvc6 razor