Serve Virtual Files from Sitecore Pipeline

For a client using Sitecore, we were asked to support a sitemap XML file. The basic requirements would be that it should be produced from items in the content tree, specifically ones that relate to actual pages. It should also work in a multi-site environment so that the output produced for “www.xyz.com/sitemap.xml” is different than “www.abc.com/sitemap.xml”.

As you’re probably aware, there are numerous solutions available on the marketplace that support such functionality. Each has their own nuance of how they approach the problem differently. Some actually write flat files; others generate the output “on the fly”. I wasn’t in love any of the ready-made solutions so at least wanted to explore the level of effort to create my own.

The most likely or accessible choice is to create a class that inherits from “Sitecore.Pipelines.HttpRequestProcessor” and inject it into the “httpRequestBegin” pipeline. We specifically want to patch it in after “Sitecore.Pipelines.HttpRequest.SiteResolver” so that the Context item will be properly populated with data we need to serve output that is specific to our site. The C# code would look something like this:

    public class SitemapProcessor : HttpRequestProcessor
    {
        private UrlOptions urlOptions;

        public override void Process(HttpRequestArgs args)
        {
            if (args.Url.FilePath != "/sitemap.xml")
            {
                return;
            }

            urlOptions = LinkManager.GetDefaultUrlOptions();
            urlOptions.AlwaysIncludeServerUrl = true;

            args.Context.Response.ClearHeaders();
            args.Context.Response.ClearContent();
            args.Context.Response.ContentType = "text/xml";

            XmlTextWriter xml = new XmlTextWriter(args.Context.Response.Output);
            xml.WriteStartDocument();
            xml.WriteStartElement("urlset", "http://www.sitemaps.org/schemas/sitemap/0.9");

            Item startItem = Context.Database.GetItem(Context.Site.StartPath);
            if (startItem != null)
            {
                WriteItem(xml, startItem);

                Item[] items = Context.Database.SelectItems("fast:" + Context.Site.StartPath + "//*");
                foreach (Item item in items)
                {
                    WriteItem(xml, item);
                }
            }

            xml.WriteEndElement();
            xml.WriteEndDocument();
            xml.Flush();

            args.Context.Response.End();
        }

        private void WriteItem(XmlTextWriter xml, Item item)
        {
            xml.WriteStartElement("url");
            xml.WriteElementString("loc", LinkManager.GetItemUrl(item, urlOptions));
            xml.WriteElementString("lastmod", item.Statistics.Updated.ToString("yyyy-MM-ddThh:mm:sszzz"));
            xml.WriteEndElement();
        }

        private bool IsPage(Item item)
        {
            bool result = false;

            LayoutField layoutField = new LayoutField(item.Fields[FieldIDs.LayoutField]);
            if (!layoutField.InnerField.HasValue || string.IsNullOrEmpty(layoutField.Value))
            {
                return false;
            }

            LayoutDefinition layout = LayoutDefinition.Parse(layoutField.Value);
            foreach (var deviceObj in layout.Devices)
            {
                var device = deviceObj as DeviceDefinition;
                if (device == null)
                {
                    return false;
                }
                if (device.Renderings.Count > 0)
                {
                    result = true;
                }
            }

            return result;
        }
    }

I’ll admit that I “borrowed” whole chunks of this code from the SimpleSitemapXml module. The one thing the developer of that module doesn’t do is have their processor listen specifically at “/sitemap.xml” which I would like to see work. However, when I attempt to run this code in my local environment with that path, it never gets called. Instead, I’ve served a generic IIS 404 page. What gives?

I tried moving my pipeline patch higher up the chain. This particular solution is running v7.2 of Sitecore so the first processor is “Sitecore.Pipelines.PreprocessRequest.CheckIgnoreFlag“. Turns out, I can only get my processor to run if I put it in front of that one but that means the SiteResolver never runs. Catch-22.

Googling that specific processor leads me to several other posts, at least one by John West, which states that the it “aborts the pipeline (reverts control to ASP.NET) if the preprocessRequest pipeline determined that Sitecore should ignore the request“. My assumption is that together Sitecore and ASP.NET see the “.xml” extension and expect that there should be a flat file. When there isn’t, they throw a 404 error. I’ve seen situations like this before when we attempted to create a “virtual” path that produces dynamic output. In this situation, however, we need a Sitecore based solution to let the system know they can safely let this request through.

A colleague of mine had run into this roadblock before and suggested actually defining two processors: one to catch the request prior to CheckIgnoreFlag and “whitelist” the request, the second to actually produce the output. The former would look something like this:

    public class SitemapPreprocessor : HttpRequestProcessor
    {
        public override void Process(HttpRequestArgs args)
        {
            if (args.Url.FilePath == "/sitemap.xml")
            {
                args.Context.Items[global::Sitecore.Constants.SitecorePipelinesOnKey] = true;
            }
        }
    }

I honestly don’t know how he found that special “SitecorePipelinesOnKey” because after he shared his solution, I googled it and nothing came up. Perhaps some inspecting through a decompiler. In any event, I felt it important to log it somewhere in the hope that future developers see it and can use it.

Conclusion

If you’re attempting to define a virtual file path within Sitecore that is handled by an HttpRequestProcessor class, you need to inform the system ahead of time by adding/setting Context.Item[Sitecore.Constants.SitecorePipelinesOnKey] to true.

Leave a Reply