24

Problem:

I need to render a Razor Page partial to a string.

Why I want this:

I want to create a controller action that responds with JSON containing a partial view and other optional parameters.

Attempts:

I am familiar with the following example that renders a View to a string: https://github.com/aspnet/Entropy/blob/dev/samples/Mvc.RenderViewToString/RazorViewToStringRenderer.cs

However, it is not compatible with Pages, as it only searches in the Views directory, so even if I give it an absolute path to the partial it tries to locate my _Layout.cshtml (which it shouldn't even do!) and fails to find it.

I have tried to modify it so it renders pages, but I end up getting a NullReferenceException for ViewData in my partial when attempting to render it. I suspect it has to do with NullView, but I have no idea what to put there instead (the constructor for RazorView requires many objects that I don't know how to get correctly).

The code:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0: https://www.apache.org/licenses/LICENSE-2.0
// Modified by OronDF343: Uses pages instead of views.

using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal;
using Microsoft.AspNetCore.Routing;

namespace TestAspNetCore.Services
{
    public class RazorPageToStringRenderer
    {
        private readonly IRazorViewEngine _viewEngine;
        private readonly ITempDataProvider _tempDataProvider;
        private readonly IServiceProvider _serviceProvider;

        public RazorPageToStringRenderer(
            IRazorViewEngine viewEngine,
            ITempDataProvider tempDataProvider,
            IServiceProvider serviceProvider)
        {
            _viewEngine = viewEngine;
            _tempDataProvider = tempDataProvider;
            _serviceProvider = serviceProvider;
        }

        public async Task<string> RenderPageToStringAsync<TModel>(string viewName, TModel model)
        {
            var actionContext = GetActionContext();
            var page = FindPage(actionContext, viewName);

            using (var output = new StringWriter())
            {
                var viewContext = new ViewContext(actionContext,
                                                  new NullView(),
                                                  new ViewDataDictionary<TModel>(new EmptyModelMetadataProvider(),
                                                                                 new ModelStateDictionary())
                                                  {
                                                      Model = model
                                                  },
                                                  new TempDataDictionary(actionContext.HttpContext,
                                                                         _tempDataProvider),
                                                  output,
                                                  new HtmlHelperOptions());

                page.ViewContext = viewContext;
                await page.ExecuteAsync();

                return output.ToString();
            }
        }

        private IRazorPage FindPage(ActionContext actionContext, string pageName)
        {
            var getPageResult = _viewEngine.GetPage(executingFilePath: null, pagePath: pageName);
            if (getPageResult.Page != null)
            {
                return getPageResult.Page;
            }

            var findPageResult = _viewEngine.FindPage(actionContext, pageName);
            if (findPageResult.Page != null)
            {
                return findPageResult.Page;
            }

            var searchedLocations = getPageResult.SearchedLocations.Concat(findPageResult.SearchedLocations);
            var errorMessage = string.Join(
                Environment.NewLine,
                new[] { $"Unable to find page '{pageName}'. The following locations were searched:" }.Concat(searchedLocations));

            throw new InvalidOperationException(errorMessage);
        }

        private ActionContext GetActionContext()
        {
            var httpContext = new DefaultHttpContext { RequestServices = _serviceProvider };
            return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
        }
    }
}
OronDF343
  • 528
  • 1
  • 3
  • 14
  • 1
    Make sure this is no "@page" directive in the Razor Page, and try again. – Dean Dec 16 '19 at 02:23
  • if you'r compiling a console app. the view .cshtml has to be physically in the same directory as the exe. I ran into another slue of problems where the global .net assemblies are not registered. – manit Jan 20 '20 at 18:49

4 Answers4

21

This is how I did it.

As always register the Service in Startup.cs

services.AddScoped<IViewRenderService, ViewRenderService>();

The Service is defined as follows:

public interface IViewRenderService
{
    Task<string> RenderToStringAsync<T>(string viewName, T model) where T : PageModel;
}

public class ViewRenderService : IViewRenderService
{
    private readonly IRazorViewEngine _razorViewEngine;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IServiceProvider _serviceProvider;
    private readonly IHttpContextAccessor _httpContext;
    private readonly IActionContextAccessor _actionContext;
    private readonly IRazorPageActivator _activator;


    public ViewRenderService(IRazorViewEngine razorViewEngine,
        ITempDataProvider tempDataProvider,
        IServiceProvider serviceProvider,
        IHttpContextAccessor httpContext,
        IRazorPageActivator activator,
        IActionContextAccessor actionContext)
    {
        _razorViewEngine = razorViewEngine;
        _tempDataProvider = tempDataProvider;
        _serviceProvider = serviceProvider;

        _httpContext = httpContext;
        _actionContext = actionContext;
        _activator = activator;

    }


    public async Task<string> RenderToStringAsync<T>(string pageName, T model) where T : PageModel
    {


        var actionContext =
            new ActionContext(
                _httpContext.HttpContext,
                _httpContext.HttpContext.GetRouteData(),
                _actionContext.ActionContext.ActionDescriptor
            );

        using (var sw = new StringWriter())
        {
            var result = _razorViewEngine.FindPage(actionContext, pageName);

            if (result.Page == null)
            {
                throw new ArgumentNullException($"The page {pageName} cannot be found.");
            }

            var view = new RazorView(_razorViewEngine,
                _activator,
                new List<IRazorPage>(),
                result.Page,
                HtmlEncoder.Default,
                new DiagnosticListener("ViewRenderService"));


            var viewContext = new ViewContext(
                actionContext,
                view,
                new ViewDataDictionary<T>(new EmptyModelMetadataProvider(), new ModelStateDictionary())
                {
                    Model = model
                },
                new TempDataDictionary(
                    _httpContext.HttpContext,
                    _tempDataProvider
                ),
                sw,
                new HtmlHelperOptions()
            );


            var page = ((Page)result.Page);

            page.PageContext = new Microsoft.AspNetCore.Mvc.RazorPages.PageContext
            {
                ViewData = viewContext.ViewData

            };

            page.ViewContext = viewContext;


            _activator.Activate(page, viewContext);

            await page.ExecuteAsync();


            return sw.ToString();
        }
    }



}

I call it like this

  emailView.Body = await this._viewRenderService.RenderToStringAsync("Email/ConfirmAccount", new Email.ConfirmAccountModel
                {
                    EmailView = emailView,
                });

"Email/ConfirmAccount" is the path to my Razor page (Under pages). "ConfirmAccountModel" is my page model for that page.

ViewData is null because the ViewData for the Page is set when the PageContext is set, so if this is not set ViewData is null.

I also found that I had to call

_activator.Activate(page, viewContext);

For it all to work. This is not fully tested yet so may not work for all scenarios but should help you get started.

Foxster
  • 211
  • 2
  • 3
  • 2
    Good job, thank you. The rendered HTML contains only partial view, it seems that _ViewStart is missing so there is no Layout in the result. I'm struggling to get the _ViewStart working. (See PageContext.ViewStartFactories property) – Sven Jun 15 '18 at 15:09
  • If someone uses net-core 2.2 and want to use this approach add to your startup.cs services.TryAddSingleton() – user2700840 May 22 '19 at 10:34
  • 2
    @Sven did you managed to get _ViewStart working and have layout string included? – Thompson04 Apr 14 '20 at 19:01
  • This works except on the page I have this: Enter Reference and it's rendering with a blank value for the href, while it works fine if it's on a page I view in a browser.. Any idea how to resolve this? – Rono Apr 28 '21 at 19:42
6

If like me you don't get GetRouteData() from _httpContext.HttpContext and _actionContext is null, you can create an extension:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace Utils
{
    public static class PageExtensions
    {
        public static async Task<string> RenderViewAsync(this PageModel pageModel, string pageName)
        {
            var actionContext = new ActionContext(
                pageModel.HttpContext,
                pageModel.RouteData,
                pageModel.PageContext.ActionDescriptor
            );

            using (var sw = new StringWriter())
            {
                IRazorViewEngine _razorViewEngine = pageModel.HttpContext.RequestServices.GetService(typeof(IRazorViewEngine)) as IRazorViewEngine;
                IRazorPageActivator _activator = pageModel.HttpContext.RequestServices.GetService(typeof(IRazorPageActivator)) as IRazorPageActivator;

                var result = _razorViewEngine.FindPage(actionContext, pageName);

                if (result.Page == null)
                {
                    throw new ArgumentNullException($"The page {pageName} cannot be found.");
                }

                var page = result.Page;

                var view = new RazorView(_razorViewEngine,
                    _activator,
                    new List<IRazorPage>(),
                    page,
                    HtmlEncoder.Default,
                    new DiagnosticListener("ViewRenderService"));


                var viewContext = new ViewContext(
                    actionContext,
                    view,
                    pageModel.ViewData,
                    pageModel.TempData,
                    sw,
                    new HtmlHelperOptions()
                );


                var pageNormal = ((Page)result.Page);

                pageNormal.PageContext = pageModel.PageContext;

                pageNormal.ViewContext = viewContext;


                _activator.Activate(pageNormal, viewContext);

                await page.ExecuteAsync();

                return sw.ToString();
            }
        }
    }
}

Note: this code only render the page being called and omit the layout.

You just have to call it from your PageModel like this:

var s = this.RenderViewAsync("sendEmail").Result;

"sendEmail" is the name of your PageModel view and the path is /Pages/sendEmail.cshtml

user3502626
  • 838
  • 11
  • 34
4

Here is the route I have gone down. Very simple and works a treat...

using System;
using System.IO;
using System.Net;

namespace gMIS.Rendering
{
    public static class RazorPage
    {
        public static string RenderToString(string url)
        {
            try
            {
                //Grab page
                WebRequest request = WebRequest.Create(url);
                WebResponse response = request.GetResponse();
                Stream data = response.GetResponseStream();
                string html = String.Empty;
                using (StreamReader sr = new StreamReader(data))
                {
                    html = sr.ReadToEnd();
                }
                return html;
            }
            catch (Exception err)
            {
                return {Handle as you see fit};
            }
        }
    }
}

Called as such....

var msg = RazorPage.RenderToString(url);

Example:

var pathToRazorPageFolder = request.PathToRazorPageFolder();

var msg = RazorPage.RenderToString($"{pathToRazorPageFolder}/Task_Summary?userGuid={userGuid}&taskId={task.Task_ID}&includelink=true&linkuserGuid={linkUserGuid}");

Above example uses this extension I created to help get the base path of my app.

namespace Microsoft.AspNetCore.Http
{
    public static class RequestExtension
    {
        public static string PathToRazorPageFolder(this HttpRequest request)
        {
            if (request != null) {
                var requestPath = request.Path.ToString();
                var returnPathToFolder = request.Scheme + "://" + request.Host + requestPath.Substring(0, requestPath.LastIndexOf("/")); ;
                return returnPathToFolder;
            } else
            {
                return "HttpRequest was null";
            }
        }
    }
}

I know that this doesn't use Dependency Injection, but man it's simple. And it just works. And works with any page no matter how it is hosted. Be that page be within or even outside your application.

  • that's just pulling html from a web page url. You don't need to call your class razor page. The question is how to generate html from a razor template within one's code. – manit Jan 20 '20 at 18:45
  • 1
    Yes it could be any page, but so happens I am using Razor Pages. It's a good point though. Could be used much wider than razor pages. – Mark Brookes Feb 19 '20 at 10:31
  • Exactly my thinking. Just download your own page. As mentioned naming of the function is maybe a little misleading. – Martin Meeser Oct 15 '22 at 14:50
2

I had the same problem.

I looked into the RazorViewEngine source code and found out that the page is searched using the "page" route data:

var routeData = new RouteData();
routeData.Values.Add("page", "/Folder/MyPage");

It's working for me with the full path "/Folder/MyPage" in the routeData, and the page name "MyPage" in the GetPage call.