Blog

Categorised by 'ASP.NET'.

  • I have some websites on a production environment that need to be run from within a subdirectory and in order to carry out proper testing during development to ensure all references to CSS, JS and images files work. By default, when a .NET Core site is run from Visual Studio it will always start from the root, resulting in a broken looking page.

    From .NET Core 2.0, within your Startup.cs file, you can set a sub-directory using the UsePathBase extension within the Configure method:

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
            app.UsePathBase("/mydirectory");
    }
    

    Now when the site runs, it’ll be accessible from /mydirectory. In my code example, I only want to set the path base if it development mode. When released to production, the path will be configured at IIS level.

    The only annoyance is when you run the site in Visual Studio, it will still start at the root and not at your newly declared subdirectory. I was surprised to see that the site is still accessible at the root, when you would expect the root path to be disabled or even greeted with a 404 response.

    On first glance, I thought there was a bug in my path base declaration and perhaps I missed something. After viewing a closed Github issue raised back in 2017, it was stated that this is in fact the intended functionality. This is a minor bug bear I can live with.

  • After renaming my MVC project from "SurinderBhomra" to "Site.Web" (to come across less self-serving) I get the following error:

    Compiler Error Message: CS0246: The type or namespace name 'xxx' could not be found (are you missing a using directive or an assembly reference?)

    CS0246 Compiler Error - Temporary ASP.NET Files

    But the misleading part of this compiler error is the source file reference to ASP.NET Temporary Files directory, which led me to believe that my build was been cached when it was actually to do with the fact I missed some areas where the old project name still remained.

    I carried out a careful rename throughout my project by updating all the places that mattered, such as:

    • Namespaces
    • Using statements
    • "AssemblyTitle" and "AssemblyProduct" attributes in AssemblyInfo.cs
    • Assembly name and Default Namespace in Project properties (followed by a rebuild)

    The key area I missed that caused the above compiler error is overlooking the the namespace section in the /Views/web.config file.

    <system.web.webpages.razor>
      <host factorytype="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.2.3.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35">
      <pages pagebasetype="System.Web.Mvc.WebViewPage">
        <namespaces>
          ...
          ...
          ...
          <add namespace="System.Web">
          ...
          ...
          ...
        </add></namespaces>
      </pages>
    </host></system.web.webpages.razor>
    

    When you first create your project in Visual Studio, it automatically adds its original namespace to this file. This also goes for any other web.config you happen to have nested in other areas inside you MVC project.

  • A picture tag allows us to serve different sized images based on different viewport breakpoints or pixel-ratios, resulting in better page load performance. Google's Pagespeed Insights negatively scores your site if responsive images aren't used. Pretty much all modern browsers support this markup and on the off chance it doesn't, an image fallback can be set.

    Using the picture markup inside page templates is pretty straight-forward, but when it comes to CMS related content where HTML editors only accommodate image tags, it's really difficult to get someone like a client to add this form of markup. So the only workaround is to transform any image tag into a picture tag at code-level.

    Code: ConvertImageToPictureTag Extension Method

    The ConvertImageToPictureTag method will perform the following tasks:

    1. Loop through all image tags.
    2. Get the URL of the image from the "src" attribute.
    3. Get other attributes such as "alt" and "style".
    4. Generate picture markup and add as many source elements based on the viewport breakpoints required, apply the URL of the image, style and alt text.
    5. Replace the original image tag with the new picture tag.

    The ConvertImageToPictureTag code uses HtmlAgilityPack, making it very easy to loop through all HTML nodes and manipulate the markup. In addition, this implementation relies on a lightweight client-side JavaScript plugin - lazysizes. The lazysizes plugin will delay the loading of the higher resolution image based on the viewport rules in the picture tag until the image is scrolled into view.

    using HtmlAgilityPack;
    using Site.Common.Kentico;
    using System;
    using System.Collections.Generic;
    using System.Collections.Specialized;
    using System.Linq;
    using System.Text;
    using System.Web;
    
    namespace SurinderBhomra.Common.Extensions
    {
        public static class ContentManipulatorExtensions
        {
            /// <summary>
            /// Transforms all image tags to a picture tag inside parsed HTML.
            /// All source image URL's need to contain a "width" query parameter in order to have a resize starting point.
            /// </summary>
            /// <param name="content"></param>
            /// <param name="percentageReduction"></param>
            /// <param name="minimumWidth">The minimum width an image has to be to warrant resizing.</param>
            /// <param name="viewPorts"></param>
            /// <returns></returns>
            public static string ConvertImageToPictureTag(this string content, int percentageReduction = 10, int minimumWidth = 200, params int[] viewPorts)
            {
                if (viewPorts?.Length == 0)
                    throw new Exception("Viewport parameter is required.");
    
                if (!string.IsNullOrEmpty(content))
                {
                    //Create a new document parser object.
                    HtmlDocument document = new HtmlDocument();
    
                    //Load the content.
                    document.LoadHtml(content);
    
                    //Get all image tags.
                    List<HtmlNode> imageNodes = document.DocumentNode.Descendants("img").ToList();
                    
                    if (imageNodes.Any())
                    {
                        // Loop through all image tags.
                        foreach (HtmlNode imgNode in imageNodes)
                        {
                            // Make sure there is an image source and it is not externally linked.
                            if (imgNode.Attributes.Contains("src") && !imgNode.Attributes["src"].Value.StartsWith("http", StringComparison.Ordinal))
                            {
                                #region Image Attributes - src, class, alt, style
                                
                                string imageSrc = imgNode.Attributes["src"].Value.Replace("~", string.Empty);
                                string imageClass = imgNode.Attributes.Contains("class") ? imgNode.Attributes["class"].Value : string.Empty;
                                string imageAlt = imgNode.Attributes.Contains("alt") ? imgNode.Attributes["alt"].Value : string.Empty;
                                string imageStyle = imgNode.Attributes.Contains("style") ? imgNode.Attributes["style"].Value : string.Empty;
    
                                #endregion
    
                                #region If Image Source has a width query parameter, this will be used as the starting size to reduce images
    
                                int imageWidth = 0;
    
                                UriBuilder imageSrcUri = new UriBuilder($"http://www.surinderbhomra.com{imageSrc}");
                                NameValueCollection imageSrcQueryParams = HttpUtility.ParseQueryString(imageSrcUri.Query);
    
                                if (imageSrcQueryParams?.Count > 0 && !string.IsNullOrEmpty(imageSrcQueryParams["width"]))
                                    imageWidth = int.Parse(imageSrcQueryParams["width"]);
    
                                // If there is no width parameter, then we cannot resize this image.
                                // Might be an older non-responsive image link.
                                if (imageWidth == 0 || imageWidth <= minimumWidth)
                                    continue;
    
                                // Clear the query string from image source.
                                imageSrc = imageSrc.ClearQueryStrings();
    
                                #endregion
    
                                // Create picture tag.
                                HtmlNode pictureNode = document.CreateElement("picture");
    
                                if (!string.IsNullOrEmpty(imageStyle))
                                    pictureNode.Attributes.Add("style", imageStyle);
    
                                #region Add multiple source tags
    
                                StringBuilder sourceHtml = new StringBuilder();
    
                                int newImageWidth = imageWidth;
                                for (int vp = 0; vp < viewPorts.Length; vp++)
                                {
                                    int viewPort = viewPorts[vp];
    
                                    // We do not not want to apply the percentage reduction to the first viewport size.
                                    // The first image should always be the original size.
                                    if (vp != 0)
                                        newImageWidth = newImageWidth - (newImageWidth * percentageReduction / 100);
    
                                    sourceHtml.Append($"<source srcset=\"{imageSrc}?width={newImageWidth}\" data-srcset=\"{imageSrc}?width={newImageWidth}\" media=\"(min-width: {viewPort}px)\">");
                                }
    
                                // Add fallback image.
                                sourceHtml.Append($"<img src=\"{imageSrc}?width=50\" style=\"width: {imageWidth}px\" class=\"{imageClass} lazyload\" alt=\"{imageAlt}\" />");
    
                                pictureNode.InnerHtml = sourceHtml.ToString();
    
                                #endregion
    
                                // Replace the image node with the new picture node.
                                imgNode.ParentNode.ReplaceChild(pictureNode, imgNode);
                            }
                        }
    
                        return document.DocumentNode.OuterHtml;
                    }
                }
    
                return content;
            }
        }
    }
    

    To use this extension, add this to any string containing HTML markup, as so:

    // The HTML markup will generate responsive images using based on the following parameters:
    // - Images to be resized in 10% increments.
    // - Images have to be more than 200px wide.
    // - Viewport sizes to take into consideration: 1000, 768, 300.
    string contentWithResponsiveImages = myHtmlContent.ConvertImageToPictureTag(10, 200, 1000, 768, 300);
    

    Sidenote

    The code I've shown doesn't carry out any image resizing, you will need to integrate that yourself. Generally, any good content management platform will have the capability to serve responsive images. In my case, I use Kentico and can resize images by adding a "width" and/or "height" query parameter to the image URL.

    In addition, all image URL's used inside an image tags "src" attribute requires a width query string parameter. The value of the width parameter will be the size the image in its largest form. Depending on the type of platform used, the URL structure to render image sizes might be different. This will be the only place where the code will need to be retrofitted to adapt to your own use case.

  • The title of this post might seem a tad extreme, but I just feel so strongly about it! Ever since I started learning ASP.NET those many years ago, I've never been a fan of using "Eval" in data-bound controls I primarily use, such as GridViews, Repeaters and DataList. When I see it still being used regularly in web applications I cringe a little and I feel I need to express some reasons to why it should stop being used.

    I think working on an application handed down to me from an external development agency pushed me to write this post... Let's call it a form of therapy! I won't make this post a rant and will "try" to be constructive and concise. My views might come across a little one-sided, but I promise I will start with at least one good thing to say about our evil friend Eval.

    Postive: Quick To Render Simple Data

    If the end goal is to list out some string values as is from the database with some minor manipulation from a relatively small dataset, I almost have no problem with that, even though I still believe it can be used and abused by inexperienced developers.

    Negative: Debugging


    The main disadvantage of embedding code inside your design file (.aspx or .ascx) is that it's not very easy to view the output during debugging. This causes a further headache when your Eval contains some conditional statements to alter the output on a row-by-row basis.

    Negative: Difficult To Carry Out Complex HTML Changes

    I wouldn't recommend using Eval in scenario's where databound rows require some form of HTML change. I've seen some ugly implementations where complex conditional statements were used to list out data in a creative way. If the HTML ever had to be changed through design updates, it would be a lot more time consuming to carry when compared to moving around some form controls that are databound through a RowDataBound event.

    Negative: Ugly To Look At

    This point will come across very superficial. Nevertheless, what I find painful to look at is when Eval is still used to carry out more functionality by calling additional methods and potentially repeating the same functionality numerous times.

    Performance/Efficiency

    From my research, it's not clear if there specifically is a performance impact in using Eval alone, especially with the advances in the .NET framework over the years. A post from 2012 on StackExchange brought up a very good point:

    Eval uses reflection to get the value of the relevant property/field, and using Reflection to get values from object members is very slow.

    If the type of an object can be determined at runtime, you're better off explicitly declaring this. After all, it's good coding standards. In the real world, the performance impact is nominal depending on the number of records you are dealing with. Not recommended for building scalable applications. I generally notice a slow down (in milliseconds) when outputting 500 rows of data.

    I have read that reflection is not as much of an issue in the most recent versions of the .NET framework when compared to say, .NET 1.1. But I am unable to find any concrete evidence of this. Regardless, I'd always prefer to use the faster approach, even if I am happening to shave off a few milliseconds in the process.

    Conclusion

    Just don't use Eval. Regardless of the size of the dataset I am dealing with, there would only be two approaches I'd ever use:

    1. RowDataBoundEvent: A controls RowDataBoundEvent event is triggered every time a row is databound with data. This approach enables us to modify the rows appearance and structure in a specific way depending on the type of rules we have in place.
    2. Start From Scratch: Construct the HTML markup by hand based on the datasource and render to the page.

    If I were to be building a scalable application dealing with thousands of rows of data, I am generally inclined to go for option 2. As you're not relying on a .NET control, you won't be contributing to the page viewstate.

    Even though I have been working on a lot more applications using MVC where I have more control on streamlining the page output, I still have to dabble with Web Forms. I feel with Web Forms, it's very easy to make a page that performs really bad, which makes it even more important to ensure you are taking all necessary steps to ensure efficiency.

  • Regardless of any site you have worked on, there is always a potential problem of a page rendering broken images. This is more likely to happen when images are served from external sources or through accidental deletion within content management platforms.

    The only way I found a way to deal with this issue, is to provide a fallback alternative if the image to be served cannot be found. I've created a FallbackImage() extension method that can be applied to any string variable that contains a path to an image.

    public static class ImageExtensions
    {
        /// <summary>
        /// Creates a fallback image if the image requested does not exist.
        /// </summary>
        /// <param name="imageUrl"></param>
        /// <returns></returns>
        public static string FallbackImage(this string imageUrl)
        {
            string cachedImagePath = CacheEngine.Get<string>(imageUrl);
    
            if (string.IsNullOrEmpty(cachedImagePath))
            {
                string sanitiseImageUrl = string.Empty;
    
                if (!imageUrl.IsExternalLink())
                    sanitiseImageUrl = $"{HttpContext.Current.GetCurrentDomain()}{imageUrl.Replace("~", string.Empty)}";
    
                // Attempt to request the image.
                WebRequest request = WebRequest.Create(sanitiseImageUrl);
    
                try
                {
                    WebResponse response = request.GetResponse();
                    cachedImagePath = imageUrl;
                }
                catch (Exception ex)
                {
                    cachedImagePath = "/resources/images/placeholder.jpg";
                }
    
                // Add image path to cache.
                CacheEngine.Add(cachedImagePath, imageUrl, 5);
            }
    
            return cachedImagePath;
        }
    }
    

    To ensure optimum performance to minimise any unnecessary checks for the same image, the request is stored in cache for 5 minutes.

    The method is using some functionality that I have developed within my own website, which will only work when referenced in your own codebase:

    • GetCurrentDomain - get the full URL of the current domain including any protocols and ports.
    • CacheEngine - provides a bunch of helper methods to interact with .NET cache provider easily.
  • Today I was working on a form that is nested inside an UpdatePanel with my work colleague. I'm not really a fan of UpdatePanel's, but I have to admit it does save time and for this form, I had to make an exception as there was a lot of dynamic loading of web controls going on.

    One part of the form required the use of a FileUpload control. Unfortunately, the FileUpload control is not supported when working alongside AJAX and requires a full page postback. If you can move the FileUpload control and corresponding button that carries out the action of uploading the file outside the UpdatePanel without breaking your form structure, that would be the most suitable approach. If not, then keep reading!

    The most well-known approach is to use PostBackTrigger - part of the UpdatePanel trigger options. When the trigger is set on the button that will action the file upload and will allow the page to carry out a full postback.

    <asp:UpdatePanel ID="FormUpdatePanel" runat="server">
        <ContentTemplate>
            <div>
                <asp:FileUpload ID="EntryFile1Upload" runat="server" />
                <br />
                <asp:Button ID="UploadButton" OnClick="UploadButton_Click" runat="server" Text="Upload File" />
            </div>
        </ContentTemplate>
        <Triggers>
            <asp:PostBackTrigger ControlID="UploadButton" />
        </Triggers>
    </asp:UpdatePanel>
    

    If your upload functionality still fails to work after adding the trigger, then you might be missing enctype attribute on the form tag. This is something I've overlooked in the past as some of the CMS's I work with add this attribute automatically. You can create the attribute at page or user control level on Page Load by simply adding the following line of code:

    this.Page.Form.Enctype = "multipart/form-data";
    
  • Earlier this week I wrote about the reasons to why I decided to use Cloudflare for my website. I've been working on utilising Cloudflare's API to purge the cache on demand for when files need to be updated within the CDN. To do this, I decided to write a method that will primarily use one API endpoint - /purge_cache. This endpoint allows a maximum of 30 URL's at one time to be purged, which is flexible enough to fit the majority of day-to-day use cases.

    To communicate with the API, we need to provide three pieces of information:

    1. Account Email Address
    2. Zone ID
    3. API Key

    The last two pieces of information can be found within the dashboard of your Cloudflare account.

    Code - CloudflareCacheHelper Class

    The CloudflareCacheHelper class consists of a single method PurgeSelectedFiles() and the following class objects used for serializing and deserializing our responses from API requests:

    • CloudflareFileInfo
    • CloudflareZone
    • CloudflareResultInfo
    • CloudflareResponse

    Not all the properties within each of the class objects are being used at the moment based on the requests I am making. But the CloudflareCacheHelper class will be updated with more methods as I delve further into Cloudflare's functionality.

    public class CloudflareCacheHelper
    {
        public string _userEmail;
        public string _apiKey;
        public string _zoneId;
    
        private readonly string ApiEndpoint = "https://api.cloudflare.com/client/v4";
    
        /// <summary>
        /// By default the Cloudflare API values will be taken from the Web.Config.
        /// </summary>
        public CloudflareCacheHelper()
        {
            _apiKey = ConfigurationManager.AppSettings["Cloudflare.ApiKey"];
            _userEmail = ConfigurationManager.AppSettings["Cloudflare.UserEmail"];
            _zoneId = ConfigurationManager.AppSettings["Cloudflare.ZoneId"];
        }
    
        /// <summary>
        /// Set the Cloudflare API values explicitly.
        /// </summary>
        /// <param name="userEmail"></param>
        /// <param name="apiKey"></param>
        /// <param name="zoneId"></param>
        public CloudflareCacheHelper(string userEmail, string apiKey, string zoneId)
        {
            _userEmail = userEmail;
            _apiKey = apiKey;
            _zoneId = zoneId;
        }
            
        /// <summary>
        /// A collection of file paths (max of 30) will be accepted for purging cache.
        /// </summary>
        /// <param name="filePaths"></param>
        /// <returns>Boolean value on success or failure.</returns>
        public bool PurgeSelectedFiles(List<string> filePaths)
        {
            CloudflareResponse purgeResponse = null;
    
            if (filePaths?.Count > 0)
            {
                try
                {
                    HttpWebRequest purgeRequest = WebRequest.CreateHttp($"{ApiEndpoint}/zones/{_zoneId}/purge_cache");
                    purgeRequest.Method = "POST";
                    purgeRequest.ContentType = "application/json";
                    purgeRequest.Headers.Add("X-Auth-Email", _userEmail);
                    purgeRequest.Headers.Add("X-Auth-Key", _apiKey);
    
                    #region Create list of Files for Submission In The Structure The Response Requires
    
                    CloudflareFileInfo fileInfo = new CloudflareFileInfo
                    {
                        Files = filePaths
                    };
    
                    byte[] data = Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(fileInfo));
    
                    purgeRequest.ContentLength = data.Length;
    
                    using (Stream fileStream = purgeRequest.GetRequestStream())
                    {
                        fileStream.Write(data, 0, data.Length);
                        fileStream.Flush();
                    }
    
                    #endregion
    
                    using (WebResponse response = purgeRequest.GetResponse())
                    {
                        using (StreamReader purgeStream = new StreamReader(response.GetResponseStream()))
                        {
                            string responseJson = purgeStream.ReadToEnd();
    
                            if (!string.IsNullOrEmpty(responseJson))
                                purgeResponse = JsonConvert.DeserializeObject<CloudflareResponse>(responseJson);
                        }
                    }
                }
                catch (Exception ex)
                {
                    throw ex;
                }
    
                return purgeResponse.Success;
            }
    
            return false;
        }
    
        #region Cloudflare Class Objects
    
        public class CloudflareFileInfo
        {
            [JsonProperty("files")]
            public List<string> Files { get; set; }
        }
    
        public class CloudflareZone
        {
            [JsonProperty("id")]
            public string Id { get; set; }
    
            [JsonProperty("type")]
            public string Type { get; set; }
    
            [JsonProperty("name")]
            public string Name { get; set; }
    
            [JsonProperty("content")]
            public string Content { get; set; }
    
            [JsonProperty("proxiable")]
            public bool Proxiable { get; set; }
    
            [JsonProperty("proxied")]
            public bool Proxied { get; set; }
    
            [JsonProperty("ttl")]
            public int Ttl { get; set; }
    
            [JsonProperty("priority")]
            public int Priority { get; set; }
    
            [JsonProperty("locked")]
            public bool Locked { get; set; }
    
            [JsonProperty("zone_id")]
            public string ZoneId { get; set; }
    
            [JsonProperty("zone_name")]
            public string ZoneName { get; set; }
    
            [JsonProperty("modified_on")]
            public DateTime ModifiedOn { get; set; }
    
            [JsonProperty("created_on")]
            public DateTime CreatedOn { get; set; }
        }
    
        public class CloudflareResultInfo
        {
            [JsonProperty("page")]
            public int Page { get; set; }
    
            [JsonProperty("per_page")]
            public int PerPage { get; set; }
    
            [JsonProperty("count")]
            public int Count { get; set; }
    
            [JsonProperty("total_count")]
            public int TotalCount { get; set; }
        }
    
        public class CloudflareResponse
        {
            [JsonProperty("result")]
            public CloudflareZone Result { get; set; }
    
            [JsonProperty("success")]
            public bool Success { get; set; }
    
            [JsonProperty("errors")]
            public IList<object> Errors { get; set; }
    
            [JsonProperty("messages")]
            public IList<object> Messages { get; set; }
    
            [JsonProperty("result_info")]
            public CloudflareResultInfo ResultInfo { get; set; }
        }
    
        #endregion
    }
    

    Example - Purging Cache of Two Files

    A string collection of URL's can be passed into the method to allow for the cache of a batch of files to be purged in a single request. If all goes well, the success response should be true.

    CloudflareCacheHelper cloudflareCache = new CloudflareCacheHelper();
    
    bool isSuccess = cloudflareCache.PurgeSelectedFiles(new List<string> {
                                        "https://www.surinderbhomra.com/getmedia/7907d934-805f-4bd3-86e7-a6b2027b4ba6/CloudflareResponseMISS.png",
                                        "https://www.surinderbhomra.com/getmedia/89679ffc-ca2f-4c47-8d41-34a6efdf7bb8/CloudflareResponseHIT.png"
                                    });
    

    Rate Limits

    The Cloudflare API sets a maximum of 1,200 requests in a five minute period. Cache-Tag purging has a lower rate limit of up to 2,000 purge API calls in every 24 hour period. You may purge up to 30 tags in one API call.

  • Published on
    -
    2 min read

    ASP.NET Core - Get Page Title By URL

    To make it easy for a client to add in related links to pages like a Blog Post or Article, I like implementing some form of automation so there is one less thing to content manage. For a Kentico Cloud project, I took this very approach. I created a UrlHelper class that will carry out the following:

    • Take in an absolute URL.
    • Read the markup of the page.
    • Selects the title tag using Regex.
    • Remove the site name prefix from title text.
    using Microsoft.Extensions.Caching.Memory;
    using MyProject.Models.Site;
    using System;
    using System.IO;
    using System.Linq;
    using System.Net;
    using System.Text.RegularExpressions;
    
    namespace MyProject.Helpers
    {
        public class UrlHelper
        {
            private static IMemoryCache _cache;
    
            public UrlHelper(IMemoryCache memCache)
            {
                _cache = memCache;
            }
    
            /// <summary>
            /// Returns the a title and URL of the link directly from a page.
            /// </summary>
            /// <param name="url"></param>
            /// <returns></returns>
            public PageLink GetPageTitleFromUrl(string url)
            {
                if (!string.IsNullOrEmpty(url))
                {
                    if (_cache.TryGetValue(url, out PageLink page))
                    {
                        return page;
                    }
                    else
                    {
                        using (WebClient client = new WebClient())
                        {
                            try
                            {
                                Stream stream = client.OpenRead(url);
                                StreamReader streamReader = new StreamReader(stream, System.Text.Encoding.GetEncoding("UTF-8"));
    
                                // Get contents of the page.
                                string pageHtml = streamReader.ReadToEnd();
    
                                if (!string.IsNullOrEmpty(pageHtml))
                                {
                                    // Get the title.
                                    string title = Regex.Match(pageHtml, @"\<title\b[^>]*\>\s*(?<Title>[\s\S]*?)\</title\>", RegexOptions.IgnoreCase).Groups["Title"].Value;
    
                                    if (!string.IsNullOrEmpty(title))
                                    {
                                        if (title.Contains("|"))
                                            title = title.Split("|").First();
                                        else if (title.Contains(":"))
                                            title = title.Split(":").First();
    
                                        PageLink pageLink = new PageLink
                                        {
                                            PageName = title,
                                            PageUrl = url
                                        };
    
                                        _cache.Set(url, pageLink, DateTimeOffset.Now.AddHours(12));
    
                                        page = pageLink;
                                    }
                                }
    
                                // Cleanup.
                                stream.Flush();
                                stream.Close();
                                client.Dispose();
                            }
                            catch (WebException e)
                            {
                                throw e;
                            }
                        }
                    }
    
                    return page;
                }
                else
                {
                    return null;
                }
            }
        }
    }
    

    The method returns a PageLink object:

    namespace MyProject.Models.Site
    {
        public class PageLink
        {
            public string PageName { get; set; }
            public string PageUrl { get; set; }
        }
    }
    

    From an efficiency standpoint, I cache the process for 12 hours as going through the process of reading the markup of a page can be quite expensive if there is a lot of HTML.

  • When building MVC websites, I cannot get through a build without using a method to convert a partial view to a string. I have blogged about this in the past and find this approach so useful especially when carrying out heavy AJAX processes. Makes the whole process of maintaining and outputting markup dynamically a walk in the park.

    I've been dealing with many more ASP.NET Core builds and migrating over the RenderPartialViewToString() extension I developed previously was not possible. Instead, I started using the approach detailed in the following StackOverflow post: Return View as String in .NET Core. Even though the approach was perfectly acceptable and did the job nicely, I noticed I had to make one key adjustment - allow for views outside controller context.

    The method proposed in the StackOverflow post uses ViewEngine.FindView(), from what I gather only returns a view within the current controller context. I added a check that will use ViewEngine.GetView() if a path of the view ends with a ".cshtml" which is normally the approach used when you refer to a view from a different controller by using a relative path.

    public static class ControllerExtensions
    {
        /// <summary>
        /// Render a partial view to string.
        /// </summary>
        /// <typeparam name="TModel"></typeparam>
        /// <param name="controller"></param>
        /// <param name="viewNamePath"></param>
        /// <param name="model"></param>
        /// <returns></returns>
        public static async Task<string> RenderViewToStringAsync<TModel>(this Controller controller, string viewNamePath, TModel model)
        {
            if (string.IsNullOrEmpty(viewNamePath))
                viewNamePath = controller.ControllerContext.ActionDescriptor.ActionName;
    
            controller.ViewData.Model = model;
    
            using (StringWriter writer = new StringWriter())
            {
                try
                {
                    IViewEngine viewEngine = controller.HttpContext.RequestServices.GetService(typeof(ICompositeViewEngine)) as ICompositeViewEngine;
    
                    ViewEngineResult viewResult = null;
    
                    if (viewNamePath.EndsWith(".cshtml"))
                        viewResult = viewEngine.GetView(viewNamePath, viewNamePath, false);
                    else
                        viewResult = viewEngine.FindView(controller.ControllerContext, viewNamePath, false);
    
                    if (!viewResult.Success)
                        return $"A view with the name '{viewNamePath}' could not be found";
    
                    ViewContext viewContext = new ViewContext(
                        controller.ControllerContext,
                        viewResult.View,
                        controller.ViewData,
                        controller.TempData,
                        writer,
                        new HtmlHelperOptions()
                    );
    
                    await viewResult.View.RenderAsync(viewContext);
    
                    return writer.GetStringBuilder().ToString();
                }
                catch (Exception exc)
                {
                    return $"Failed - {exc.Message}";
                }
            }
        }
    
        /// <summary>
        /// Render a partial view to string, without a model present.
        /// </summary>
        /// <typeparam name="TModel"></typeparam>
        /// <param name="controller"></param>
        /// <param name="viewNamePath"></param>
        /// <returns></returns>
        public static async Task<string> RenderViewToStringAsync(this Controller controller, string viewNamePath)
        {
            if (string.IsNullOrEmpty(viewNamePath))
                viewNamePath = controller.ControllerContext.ActionDescriptor.ActionName;
                
            using (StringWriter writer = new StringWriter())
            {
                try
                {
                    IViewEngine viewEngine = controller.HttpContext.RequestServices.GetService(typeof(ICompositeViewEngine)) as ICompositeViewEngine;
    
                    ViewEngineResult viewResult = null;
    
                    if (viewNamePath.EndsWith(".cshtml"))
                        viewResult = viewEngine.GetView(viewNamePath, viewNamePath, false);
                    else
                        viewResult = viewEngine.FindView(controller.ControllerContext, viewNamePath, false);
    
                    if (!viewResult.Success)
                        return $"A view with the name '{viewNamePath}' could not be found";
    
                    ViewContext viewContext = new ViewContext(
                        controller.ControllerContext,
                        viewResult.View,
                        controller.ViewData,
                        controller.TempData,
                        writer,
                        new HtmlHelperOptions()
                    );
    
                    await viewResult.View.RenderAsync(viewContext);
    
                    return writer.GetStringBuilder().ToString();
                }
                catch (Exception exc)
                {
                    return $"Failed - {exc.Message}";
                }
            }
        }
    
    }
    

    Quick Example

    As you can see from my quick example below, the Home controller is using the RenderViewToStringAsync() when calling:

    • A view from another controller, where a relative path to the view is used.
    • A view from within the realms of the current controller and the name of the view alone can be used.
    public class HomeController : Controller
    {
        public async Task<IActionResult> Index()
        {
            NewsListItem newsItem = GetSingleNewsItem(); // Get a single news item.
    
            string viewFromAnotherController = await this.RenderViewToStringAsync("/Views/News/_NewsList.cshtml", newsItem);
            string viewFromCurrentController = await this.RenderViewToStringAsync("_NewsListHome", newsItem);
    
            return View();
        }
    }
    
  • It seems whenever I work on an ASP.NET Core website, I always seem to get the most unhelpful error when deploying to production:

    HTTP Error 502.5 - Process Failure

    I have no problem running the ASP.NET Core site whilst developing from within a local environment.

    From past experience, the HTTP 502.5 error generally happens for the following reasons:

    1. The ASP.NET Core framework is not installed or your site is running the incorrect version.
    2. Website project incorrectly published.
    3. Potential configuration issue at code level.

    Generally when you successfully publish a deployable version of your site, you'd expect it to just work. To get around the deployment woes, the solution is to modify your .csproj file by adding the following setting:

    <PropertyGroup>
       <PublishWithAspNetCoreTargetManifest>false</PublishWithAspNetCoreTargetManifest>
    </PropertyGroup>
    

    Once this setting has been added, you'll notice when your site is re-published a whole bunch of new DLL files are now present, forming part of all the dependencies a site requires. It's strange a normal publish does not do this already and what's even stranger is I have a different .NET Core site running without having to take this approach.

    For any new .NET Core sites I work on, I will be using approach going forward.

    Useful Links