// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Mvc.ViewEngines; using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Mvc.Razor { /// /// Default implementation of . /// /// /// For ViewResults returned from controllers, views should be located in /// by default. For the controllers in an area, views should exist in . /// public class RazorViewEngine : IRazorViewEngine { private const string ViewExtension = ".cshtml"; internal const string ControllerKey = "controller"; internal const string AreaKey = "area"; private static readonly IEnumerable _viewLocationFormats = new[] { "/Views/{1}/{0}" + ViewExtension, "/Views/Shared/{0}" + ViewExtension, }; private static readonly IEnumerable _areaViewLocationFormats = new[] { "/Areas/{2}/Views/{1}/{0}" + ViewExtension, "/Areas/{2}/Views/Shared/{0}" + ViewExtension, "/Views/Shared/{0}" + ViewExtension, }; private readonly IRazorPageFactory _pageFactory; private readonly IRazorViewFactory _viewFactory; private readonly IList _viewLocationExpanders; private readonly IViewLocationCache _viewLocationCache; /// /// Initializes a new instance of the class. /// /// The page factory used for creating instances. public RazorViewEngine( IRazorPageFactory pageFactory, IRazorViewFactory viewFactory, IOptions optionsAccessor, IViewLocationCache viewLocationCache) { _pageFactory = pageFactory; _viewFactory = viewFactory; _viewLocationExpanders = optionsAccessor.Value.ViewLocationExpanders; _viewLocationCache = viewLocationCache; } /// /// Gets the locations where this instance of will search for views. /// /// /// The locations of the views returned from controllers that do not belong to an area. /// Locations are composite format strings (see http://msdn.microsoft.com/en-us/library/txafckwd.aspx), /// which contains following indexes: /// {0} - Action Name /// {1} - Controller Name /// The values for these locations are case-sensitive on case-senstive file systems. /// For example, the view for the Test action of HomeController should be located at /// /Views/Home/Test.cshtml. Locations such as /views/home/test.cshtml would not be discovered /// public virtual IEnumerable ViewLocationFormats { get { return _viewLocationFormats; } } /// /// Gets the locations where this instance of will search for views within an /// area. /// /// /// The locations of the views returned from controllers that belong to an area. /// Locations are composite format strings (see http://msdn.microsoft.com/en-us/library/txafckwd.aspx), /// which contains following indexes: /// {0} - Action Name /// {1} - Controller Name /// {2} - Area name /// The values for these locations are case-sensitive on case-senstive file systems. /// For example, the view for the Test action of HomeController should be located at /// /Views/Home/Test.cshtml. Locations such as /views/home/test.cshtml would not be discovered /// public virtual IEnumerable AreaViewLocationFormats { get { return _areaViewLocationFormats; } } /// public ViewEngineResult FindView( ActionContext context, string viewName) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (string.IsNullOrEmpty(viewName)) { throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewName)); } var pageResult = GetRazorPageResult(context, viewName, isPartial: false); return CreateViewEngineResult(pageResult, _viewFactory, isPartial: false); } /// public ViewEngineResult FindPartialView( ActionContext context, string partialViewName) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (string.IsNullOrEmpty(partialViewName)) { throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(partialViewName)); } var pageResult = GetRazorPageResult(context, partialViewName, isPartial: true); return CreateViewEngineResult(pageResult, _viewFactory, isPartial: true); } /// public RazorPageResult FindPage( ActionContext context, string pageName) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (string.IsNullOrEmpty(pageName)) { throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pageName)); } return GetRazorPageResult(context, pageName, isPartial: true); } /// /// Gets the case-normalized route value for the specified route . /// /// The . /// The route key to lookup. /// The value corresponding to the key. /// /// The casing of a route value in is determined by the client. /// This making constructing paths for view locations in a case sensitive file system unreliable. Using the /// for attribute routes and /// for traditional routes to get route values /// produces consistently cased results. /// public static string GetNormalizedRouteValue( ActionContext context, string key) { if (context == null) { throw new ArgumentNullException(nameof(context)); } if (key == null) { throw new ArgumentNullException(nameof(key)); } object routeValue; if (!context.RouteData.Values.TryGetValue(key, out routeValue)) { return null; } var actionDescriptor = context.ActionDescriptor; string normalizedValue = null; if (actionDescriptor.AttributeRouteInfo != null) { object match; if (actionDescriptor.RouteValueDefaults.TryGetValue(key, out match)) { normalizedValue = match?.ToString(); } } else { // Perf: Avoid allocations for (var i = 0; i < actionDescriptor.RouteConstraints.Count; i++) { var constraint = actionDescriptor.RouteConstraints[i]; if (string.Equals(constraint.RouteKey, key, StringComparison.Ordinal)) { if (constraint.KeyHandling == RouteKeyHandling.DenyKey) { return null; } else if (constraint.KeyHandling == RouteKeyHandling.RequireKey) { normalizedValue = constraint.RouteValue; } // Duplicate keys in RouteConstraints are not allowed. break; } } } var stringRouteValue = routeValue?.ToString(); if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) { return normalizedValue; } return stringRouteValue; } private RazorPageResult GetRazorPageResult( ActionContext context, string pageName, bool isPartial) { if (IsApplicationRelativePath(pageName)) { var applicationRelativePath = pageName; if (!pageName.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)) { applicationRelativePath += ViewExtension; } var page = _pageFactory.CreateInstance(applicationRelativePath); if (page != null) { return new RazorPageResult(pageName, page); } return new RazorPageResult(pageName, new[] { pageName }); } else { return LocatePageFromViewLocations(context, pageName, isPartial); } } private RazorPageResult LocatePageFromViewLocations( ActionContext context, string pageName, bool isPartial) { // Initialize the dictionary for the typical case of having controller and action tokens. var areaName = GetNormalizedRouteValue(context, AreaKey); // Only use the area view location formats if we have an area token. var viewLocations = !string.IsNullOrEmpty(areaName) ? AreaViewLocationFormats : ViewLocationFormats; var expanderContext = new ViewLocationExpanderContext(context, pageName, isPartial); if (_viewLocationExpanders.Count > 0) { expanderContext.Values = new Dictionary(StringComparer.Ordinal); // 1. Populate values from viewLocationExpanders. // Perf: Avoid allocations for( var i = 0; i < _viewLocationExpanders.Count; i++) { _viewLocationExpanders[i].PopulateValues(expanderContext); } } // 2. With the values that we've accumumlated so far, check if we have a cached result. IEnumerable locationsToSearch = null; var cachedResult = _viewLocationCache.Get(expanderContext); if (!cachedResult.Equals(ViewLocationCacheResult.None)) { if (cachedResult.IsFoundResult) { var page = _pageFactory.CreateInstance(cachedResult.ViewLocation); if (page != null) { // 2a We have a cache entry where a view was previously found. return new RazorPageResult(pageName, page); } } else { locationsToSearch = cachedResult.SearchedLocations; } } if (locationsToSearch == null) { // 2b. We did not find a cached location or did not find a IRazorPage at the cached location. // The cached value has expired and we need to look up the page. foreach (var expander in _viewLocationExpanders) { viewLocations = expander.ExpandViewLocations(expanderContext, viewLocations); } var controllerName = GetNormalizedRouteValue(context, ControllerKey); locationsToSearch = viewLocations.Select( location => string.Format( CultureInfo.InvariantCulture, location, pageName, controllerName, areaName )); } // 3. Use the expanded locations to look up a page. var searchedLocations = new List(); foreach (var path in locationsToSearch) { var page = _pageFactory.CreateInstance(path); if (page != null) { // 3a. We found a page. Cache the set of values that produced it and return a found result. _viewLocationCache.Set(expanderContext, new ViewLocationCacheResult(path, searchedLocations)); return new RazorPageResult(pageName, page); } searchedLocations.Add(path); } // 3b. We did not find a page for any of the paths. _viewLocationCache.Set(expanderContext, new ViewLocationCacheResult(searchedLocations)); return new RazorPageResult(pageName, searchedLocations); } private ViewEngineResult CreateViewEngineResult( RazorPageResult result, IRazorViewFactory razorViewFactory, bool isPartial) { if (result.SearchedLocations != null) { return ViewEngineResult.NotFound(result.Name, result.SearchedLocations); } var view = razorViewFactory.GetView(this, result.Page, isPartial); return ViewEngineResult.Found(result.Name, view); } private static bool IsApplicationRelativePath(string name) { Debug.Assert(!string.IsNullOrEmpty(name)); return name[0] == '~' || name[0] == '/'; } } }