Adding support for ViewLocationExpanders to allow modifying view locations

without changing the view engine.

Fixes #1039
This commit is contained in:
Pranav K 2014-09-03 16:20:24 -07:00
parent a4fff7a2e0
commit ad8ab4b8fd
50 changed files with 1448 additions and 99 deletions

View File

@ -97,6 +97,11 @@ namespace MvcSample.Web
Context.Response.WriteAsync("Hello World raw");
}
public ActionResult Language()
{
return View();
}
[Produces("application/json", "application/xml", "application/custom", "text/json", Type = typeof(User))]
public object ReturnUser()
{

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.Razor;
namespace MvcSample.Web
{
/// <summary>
/// A <see cref="IViewLocationExpander"/> that replaces adds the language as an extension prefix to view names.
/// </summary>
/// <example>
/// For the default case with no areas, views are generated with the following patterns (assuming controller is
/// "Home", action is "Index" and language is "en")
/// Views/Home/en/Action
/// Views/Home/Action
/// Views/Shared/en/Action
/// Views/Shared/Action
/// </example>
public class LanguageViewLocationExpander : IViewLocationExpander
{
private const string ValueKey = "language";
private readonly Func<ActionContext, string> _valueFactory;
/// <summary>
/// Initializes a new instance of <see cref="LanguageViewLocationExpander"/>.
/// </summary>
/// <param name="valueFactory">A factory that provides tbe language to use for expansion.</param>
public LanguageViewLocationExpander(Func<ActionContext, string> valueFactory)
{
_valueFactory = valueFactory;
}
/// <inheritdoc />
public void PopulateValues(ViewLocationExpanderContext context)
{
var value = _valueFactory(context.ActionContext);
if (!string.IsNullOrEmpty(value))
{
context.Values[ValueKey] = value;
}
}
/// <inheritdoc />
public virtual IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
string value;
if (context.Values.TryGetValue(ValueKey, out value))
{
return ExpandViewLocationsCore(viewLocations, value);
}
return viewLocations;
}
private IEnumerable<string> ExpandViewLocationsCore(IEnumerable<string> viewLocations,
string value)
{
foreach (var location in viewLocations)
{
yield return location.Replace("{0}", value + "/{0}");
yield return location;
}
}
}
}

View File

@ -1,10 +1,10 @@
using System;
using Microsoft.AspNet.Builder;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.Razor;
using Microsoft.AspNet.Routing;
using Microsoft.Framework.ConfigurationModel;
using Microsoft.Framework.DependencyInjection;
using Microsoft.Framework.OptionsModel;
using MvcSample.Web.Filters;
using MvcSample.Web.Services;
@ -46,6 +46,12 @@ namespace MvcSample.Web
{
options.Filters.Add(typeof(PassThroughAttribute), order: 17);
});
services.SetupOptions<RazorViewEngineOptions>(options =>
{
var expander = new LanguageViewLocationExpander(
context => context.HttpContext.Request.Query["language"]);
options.ViewLocationExpanders.Insert(0, expander);
});
// Create the autofac container
ContainerBuilder builder = new ContainerBuilder();

View File

@ -0,0 +1,5 @@
<ul>
<li>COLOR</li>
<li>HUMOR</li>
<li>ITEMIZE</li>
</ul>

View File

@ -0,0 +1,5 @@
<ul>
<li>COLOUR</li>
<li>HUMOUR</li>
<li>ITEMISE</li>
</ul>

View File

@ -0,0 +1,5 @@
<ul>
<li>COULEUR</li>
<li>HUMOUR</li>
<li>DÉTAILLER</li>
</ul>

View File

@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewBag.Title - My ASP.NET Application</title>
<link rel="stylesheet" href="~/content/bootstrap.min.css" />
<style>
body { padding-top: 60px; }
@@media screen and (max-width: 768px) {
body { padding-top: 0px; }
}
</style>
@RenderSection("header", required: false)
</head>
<body>

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.AspNet.Mvc.Core;
using Microsoft.AspNet.Mvc.OptionDescriptors;
using Microsoft.AspNet.Mvc.ReflectedModelBuilder;
@ -16,7 +15,6 @@ namespace Microsoft.AspNet.Mvc
public class MvcOptions
{
private AntiForgeryOptions _antiForgeryOptions = new AntiForgeryOptions();
private RazorViewEngineOptions _viewEngineOptions = new RazorViewEngineOptions();
private int _maxModelStateErrors = 200;
public MvcOptions()
@ -71,29 +69,6 @@ namespace Microsoft.AspNet.Mvc
/// </summary>
public List<InputFormatterDescriptor> InputFormatters { get; private set; }
/// <summary>
/// Provides programmatic configuration for the default <see cref="Rendering.IViewEngine" />.
/// </summary>
public RazorViewEngineOptions ViewEngineOptions
{
get
{
return _viewEngineOptions;
}
set
{
if (value == null)
{
throw new ArgumentNullException("value",
Resources.FormatPropertyOfTypeCannotBeNull("ViewEngineOptions",
typeof(MvcOptions)));
}
_viewEngineOptions = value;
}
}
/// <summary>
/// Gets or sets the maximum number of validation errors that are allowed by this application before further
/// errors are ignored.
@ -123,8 +98,8 @@ namespace Microsoft.AspNet.Mvc
/// Gets a list of the <see cref="ModelValidatorProviderDescriptor" />s used by
/// <see cref="ModelBinding.CompositeModelValidatorProvider"/>.
/// </summary>
public List<ModelValidatorProviderDescriptor> ModelValidatorProviders { get; } =
new List<ModelValidatorProviderDescriptor>();
public List<ModelValidatorProviderDescriptor> ModelValidatorProviders { get; }
= new List<ModelValidatorProviderDescriptor>();
/// <summary>
/// Gets a list of descriptors that represent <see cref="Rendering.IViewEngine"/> used

View File

@ -1354,6 +1354,54 @@ namespace Microsoft.AspNet.Mvc.Core
return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_AggregateErrorMessage_ErrorNumber"), p0, p1, p2);
}
/// <summary>
/// Could not find a replacement for view expansion token '{0}'.
/// </summary>
internal static string TemplatedViewLocationExpander_NoReplacementToken
{
get { return GetString("TemplatedViewLocationExpander_NoReplacementToken"); }
}
/// <summary>
/// Could not find a replacement for view expansion token '{0}'.
/// </summary>
internal static string FormatTemplatedViewLocationExpander_NoReplacementToken(object p0)
{
return string.Format(CultureInfo.CurrentCulture, GetString("TemplatedViewLocationExpander_NoReplacementToken"), p0);
}
/// <summary>
/// {0} must be executed before {1} can be invoked.
/// </summary>
internal static string TemplatedExpander_PopulateValuesMustBeInvokedFirst
{
get { return GetString("TemplatedExpander_PopulateValuesMustBeInvokedFirst"); }
}
/// <summary>
/// {0} must be executed before {1} can be invoked.
/// </summary>
internal static string FormatTemplatedExpander_PopulateValuesMustBeInvokedFirst(object p0, object p1)
{
return string.Format(CultureInfo.CurrentCulture, GetString("TemplatedExpander_PopulateValuesMustBeInvokedFirst"), p0, p1);
}
/// <summary>
/// The result of value factory cannot be null.
/// </summary>
internal static string TemplatedExpander_ValueFactoryCannotReturnNull
{
get { return GetString("TemplatedExpander_ValueFactoryCannotReturnNull"); }
}
/// <summary>
/// The result of value factory cannot be null.
/// </summary>
internal static string FormatTemplatedExpander_ValueFactoryCannotReturnNull()
{
return GetString("TemplatedExpander_ValueFactoryCannotReturnNull");
}
/// <summary>
/// A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4}
/// </summary>

View File

@ -375,6 +375,15 @@
<value>Error {0}:{1}{2}</value>
<comment>{0} is the error number, {1} is Environment.NewLine {2} is the error message</comment>
</data>
<data name="TemplatedViewLocationExpander_NoReplacementToken" xml:space="preserve">
<value>Could not find a replacement for view expansion token '{0}'.</value>
</data>
<data name="TemplatedExpander_PopulateValuesMustBeInvokedFirst" xml:space="preserve">
<value>{0} must be executed before {1} can be invoked.</value>
</data>
<data name="TemplatedExpander_ValueFactoryCannotReturnNull" xml:space="preserve">
<value>The result of value factory cannot be null.</value>
</data>
<data name="AttributeRoute_InvalidHttpConstraints" xml:space="preserve">
<value>A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4}</value>
<comment>{0} is the MethodInfo.FullName, {1} is typeof(IActionHttpMethodProvider).FullName, {2} is typeof(IRouteTemplateProvider).FullName, {3} is Environment.NewLine, {4} is the list of actions and their respective invalid IActionHttpMethodProvider attributes formatted using AttributeRoute_InvalidHttpMethodConstraints_Item</comment>

View File

@ -11,7 +11,7 @@
"Microsoft.AspNet.Mvc.ModelBinding": "",
"Microsoft.AspNet.Routing": "1.0.0-*",
"Microsoft.AspNet.Security": "1.0.0-*",
"Microsoft.AspNet.Security.DataProtection" : "1.0.0-*",
"Microsoft.AspNet.Security.DataProtection": "1.0.0-*",
"Microsoft.Framework.DependencyInjection": "1.0.0-*",
"Microsoft.Framework.Runtime.Interfaces": "1.0.0-*",
"Microsoft.Framework.Logging": "1.0.0-*",

View File

@ -7,7 +7,7 @@ using Microsoft.AspNet.FileSystems;
using Microsoft.Framework.OptionsModel;
using Microsoft.Framework.Runtime;
namespace Microsoft.AspNet.Mvc.Core
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// A default implementation for the <see cref="IFileInfoCache"/> interface.
@ -37,11 +37,11 @@ namespace Microsoft.AspNet.Mvc.Core
}
public ExpiringFileInfoCache(IApplicationEnvironment env,
IOptionsAccessor<MvcOptions> optionsAccessor)
IOptionsAccessor<RazorViewEngineOptions> optionsAccessor)
{
// TODO: Inject the IFileSystem but only when we get it from the host
_fileSystem = new PhysicalFileSystem(env.ApplicationBasePath);
_offset = optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk;
_offset = optionsAccessor.Options.ExpirationBeforeCheckingFilesOnDisk;
}
/// <inheritdoc />

View File

@ -3,7 +3,7 @@
using Microsoft.AspNet.FileSystems;
namespace Microsoft.AspNet.Mvc.Core
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Provides cached access to file infos.

View File

@ -0,0 +1,81 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Default implementation of <see cref="IViewLocationCache"/>.
/// </summary>
public class DefaultViewLocationCache : IViewLocationCache
{
private const char CacheKeySeparator = ':';
// A mapping of keys generated from ViewLocationExpanderContext to view locations.
private readonly ConcurrentDictionary<string, string> _cache;
/// <summary>
/// Initializes a new instance of <see cref="DefaultViewLocationCache"/>.
/// </summary>
public DefaultViewLocationCache()
{
_cache = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
}
/// <inheritdoc />
public string Get([NotNull] ViewLocationExpanderContext context)
{
var cacheKey = GenerateKey(context);
string result;
_cache.TryGetValue(cacheKey, out result);
return result;
}
/// <inheritdoc />
public void Set([NotNull] ViewLocationExpanderContext context,
[NotNull] string value)
{
var cacheKey = GenerateKey(context);
_cache.TryAdd(cacheKey, value);
}
internal static string GenerateKey(ViewLocationExpanderContext context)
{
var keyBuilder = new StringBuilder();
var routeValues = context.ActionContext.RouteData.Values;
var controller = routeValues.GetValueOrDefault<string>(RazorViewEngine.ControllerKey);
// format is "{viewName}:{controllerName}:{areaName}:"
keyBuilder.Append(context.ViewName)
.Append(CacheKeySeparator)
.Append(controller);
var area = routeValues.GetValueOrDefault<string>(RazorViewEngine.AreaKey);
if (!string.IsNullOrEmpty(area))
{
keyBuilder.Append(CacheKeySeparator)
.Append(area);
}
if (context.Values != null)
{
var valuesDictionary = context.Values;
foreach (var item in valuesDictionary.OrderBy(k => k.Key, StringComparer.Ordinal))
{
keyBuilder.Append(CacheKeySeparator)
.Append(item.Key)
.Append(CacheKeySeparator)
.Append(item.Value);
}
}
var cacheKey = keyBuilder.ToString();
return cacheKey;
}
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Specifies the contracts for caching view locations generated by <see cref="IViewLocationExpander"/>.
/// </summary>
public interface IViewLocationCache
{
/// <summary>
/// Gets a cached view location based on the specified <paramref name="context"/>.
/// </summary>
/// <param name="context">The <see cref="ViewLocationExpanderContext"/> for the current view location
/// expansion.</param>
/// <returns>The cached location, if available, <c>null</c> otherwise.</returns>
string Get(ViewLocationExpanderContext context);
/// <summary>
/// Adds a cache entry for values specified by <paramref name="context"/>.
/// </summary>
/// <param name="context">The <see cref="ViewLocationExpanderContext"/> for the current view location
/// expansion.</param>
/// <param name="value">The view location that is to be cached.</param>
void Set(ViewLocationExpanderContext context, string value);
}
}

View File

@ -0,0 +1,45 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Specifies the contracts for a view location expander that is used by <see cref="RazorViewEngine"/> instances to
/// determine search paths for a view.
/// </summary>
/// <remarks>
/// Individual <see cref="IViewLocationExpander"/>s are invoked in two steps:
/// (1) <see cref="PopulateValues(ViewLocationExpanderContext)"/> is invoked and each expander
/// adds values that it would later consume as part of
/// <see cref="ExpandViewLocations(ViewLocationExpanderContext, IEnumerable{string})"/>.
/// The populated values are used to determine a cache key - if all values are identical to the last time
/// <see cref="PopulateValues(ViewLocationExpanderContext)"/> was invoked, the cached result
/// is used as the view location.
/// (2) If no result was found in the cache or if a view was not found at the cached location,
/// <see cref="ExpandViewLocations(ViewLocationExpanderContext, IEnumerable{string})"/> is invoked to determine
/// all potential paths for a view.
/// </remarks>
public interface IViewLocationExpander
{
/// <summary>
/// Invoked by a <see cref="RazorViewEngine"/> to determine the values that would be consumed by this instance of
/// <see cref="IViewLocationExpander"/>. The calculated values are used to determine if the view location has
/// changed since the last time it was located.
/// </summary>
/// <param name="context">The <see cref="ViewLocationExpanderContext"/> for the current view location
/// expansion operation.</param>
void PopulateValues(ViewLocationExpanderContext context);
/// <summary>
/// Invoked by a <see cref="RazorViewEngine"/> to determine potential locations for a view.
/// </summary>
/// <param name="context">The <see cref="ViewLocationExpanderContext"/> for the current view location
/// expansion operation.</param>
/// <param name="values">The sequence of view locations to expand.</param>
/// <returns>A list of expanded view locations.</returns>
IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations);
}
}

View File

@ -0,0 +1,37 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Mvc.OptionDescriptors;
using Microsoft.Framework.DependencyInjection;
using Microsoft.Framework.OptionsModel;
namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors
{
/// <inheritdoc />
public class DefaultViewLocationExpanderProvider :
OptionDescriptorBasedProvider<IViewLocationExpander>, IViewLocationExpanderProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="DefaultViewLocationExpanderProvider"/> class.
/// </summary>
/// <param name="options">An accessor to the <see cref="MvcOptions"/> configured for this application.</param>
/// <param name="typeActivator">An <see cref="ITypeActivator"/> instance used to instantiate types.</param>
/// <param name="serviceProvider">A <see cref="IServiceProvider"/> instance that retrieves services from the
/// service collection.</param>
public DefaultViewLocationExpanderProvider(
IOptionsAccessor<RazorViewEngineOptions> optionsAccessor,
ITypeActivator typeActivator,
IServiceProvider serviceProvider)
: base(optionsAccessor.Options.ViewLocationExpanders, typeActivator, serviceProvider)
{
}
/// <inheritdoc />
public IReadOnlyList<IViewLocationExpander> ViewLocationExpanders
{
get { return Options; }
}
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors
{
/// <summary>
/// Provides an activated collection of <see cref="IViewLocationExpander"/> instances.
/// </summary>
public interface IViewLocationExpanderProvider
{
/// <summary>
/// Gets a collection of activated <see cref="IViewLocationExpander"/> instances.
/// </summary>
IReadOnlyList<IViewLocationExpander> ViewLocationExpanders { get; }
}
}

View File

@ -2,8 +2,10 @@
// 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
namespace Microsoft.AspNet.Mvc.Core
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Provides programmatic configuration for the default <see cref="Microsoft.AspNet.Mvc.Rendering.IViewEngine"/>.
@ -38,5 +40,12 @@ namespace Microsoft.AspNet.Mvc.Core
}
}
}
/// <summary>
/// Get a <see cref="IList{T}"/> of descriptors for <see cref="IViewLocationExpander" />s used by this
/// application.
/// </summary>
public IList<ViewLocationExpanderDescriptor> ViewLocationExpanders { get; }
= new List<ViewLocationExpanderDescriptor>();
}
}

View File

@ -0,0 +1,34 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNet.Mvc.OptionDescriptors;
namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors
{
/// <summary>
/// Encapsulates information that describes an <see cref="IViewLocationExpander"/>.
/// </summary>
public class ViewLocationExpanderDescriptor : OptionDescriptor<IViewLocationExpander>
{
/// <summary>
/// Creates a new instance of <see cref="ViewLocationExpanderDescriptor"/>.
/// </summary>
/// <param name="type">A <see cref="IViewLocationExpander"/> type that the descriptor represents.
/// </param>
public ViewLocationExpanderDescriptor([NotNull] Type type)
: base(type)
{
}
/// <summary>
/// Creates a new instance of <see cref="ViewLocationExpanderDescriptor"/>.
/// </summary>
/// <param name="viewLocationExpander">An instance of <see cref="IViewLocationExpander"/>
/// that the descriptor represents.</param>
public ViewLocationExpanderDescriptor([NotNull] IViewLocationExpander viewLocationExpander)
: base(viewLocationExpander)
{
}
}
}

View File

@ -0,0 +1,93 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// Extension methods for adding view location expanders to a collection.
/// </summary>
public static class ViewLocationExpanderDescriptorExtensions
{
/// <summary>
/// Adds a type representing a <see cref="IViewLocationExpander"/> to <paramref name="descriptors"/>.
/// </summary>
/// <param name="descriptors">A list of <see cref="ViewLocationExpanderDescriptor"/>.</param>
/// <param name="viewLocationExpanderType">Type representing an <see cref="IViewLocationExpander"/></param>
/// <returns>A <see cref="ViewLocationExpanderDescriptor"/> representing the added instance.</returns>
public static ViewLocationExpanderDescriptor Add(
[NotNull] this IList<ViewLocationExpanderDescriptor> descriptors,
[NotNull] Type viewLocationExpanderType)
{
var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpanderType);
descriptors.Add(descriptor);
return descriptor;
}
/// <summary>
/// Inserts a type representing a <see cref="IViewLocationExpander"/> in to <paramref name="descriptors"/> at
/// the specified <paramref name="index"/>.
/// </summary>
/// <param name="descriptors">A list of <see cref="ViewLocationExpanderDescriptor"/>.</param>
/// <param name="index">The zero-based index at which <paramref name="viewLocationExpanderType"/>
/// should be inserted.</param>
/// <param name="viewLocationExpanderType">Type representing an <see cref="IViewLocationExpander"/></param>
/// <returns>A <see cref="ViewLocationExpanderDescriptor"/> representing the inserted instance.</returns>
public static ViewLocationExpanderDescriptor Insert(
[NotNull] this IList<ViewLocationExpanderDescriptor> descriptors,
int index,
[NotNull] Type viewLocationExpanderType)
{
if (index < 0 || index > descriptors.Count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpanderType);
descriptors.Insert(index, descriptor);
return descriptor;
}
/// <summary>
/// Adds an <see cref="IViewLocationExpander"/> to <paramref name="descriptors"/>.
/// </summary>
/// <param name="descriptors">A list of <see cref="ViewLocationExpanderDescriptor"/>.</param>
/// <param name="viewLocationExpander">An <see cref="IViewLocationExpander"/> instance.</param>
/// <returns>A <see cref="ViewLocationExpanderDescriptor"/> representing the added instance.</returns>
public static ViewLocationExpanderDescriptor Add(
[NotNull] this IList<ViewLocationExpanderDescriptor> descriptors,
[NotNull] IViewLocationExpander viewLocationExpander)
{
var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpander);
descriptors.Add(descriptor);
return descriptor;
}
/// <summary>
/// Insert an <see cref="IViewLocationExpander"/> in to <paramref name="descriptors"/> at the specified
/// <paramref name="index"/>.
/// </summary>
/// <param name="descriptors">A list of <see cref="ViewLocationExpanderDescriptor"/>.</param>
/// <param name="index">The zero-based index at which <paramref name="viewLocationExpander"/>
/// should be inserted.</param>
/// <param name="viewLocationExpander">An <see cref="IViewLocationExpander"/> instance.</param>
/// <returns>A <see cref="ViewLocationExpanderDescriptor"/> representing the added instance.</returns>
public static ViewLocationExpanderDescriptor Insert(
[NotNull] this IList<ViewLocationExpanderDescriptor> descriptors,
int index,
[NotNull] IViewLocationExpander viewLocationExpander)
{
if (index < 0 || index > descriptors.Count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpander);
descriptors.Insert(index, descriptor);
return descriptor;
}
}
}

View File

@ -266,6 +266,22 @@ namespace Microsoft.AspNet.Mvc.Razor
return string.Format(CultureInfo.CurrentCulture, GetString("ViewContextMustBeSet"), p0, p1);
}
/// <summary>
/// '{0}' must be a {1} that is generated as result of the call to '{2}'.
/// </summary>
internal static string ViewLocationCache_KeyMustBeString
{
get { return GetString("ViewLocationCache_KeyMustBeString"); }
}
/// <summary>
/// '{0}' must be a {1} that is generated as result of the call to '{2}'.
/// </summary>
internal static string FormatViewLocationCache_KeyMustBeString(object p0, object p1, object p2)
{
return string.Format(CultureInfo.CurrentCulture, GetString("ViewLocationCache_KeyMustBeString"), p0, p1, p2);
}
/// <summary>
/// The '{0}' method must be called before '{1}' can be invoked.
/// </summary>

View File

@ -4,7 +4,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.Framework.DependencyInjection;
@ -16,14 +16,16 @@ namespace Microsoft.AspNet.Mvc.Razor
public class RazorViewEngine : IViewEngine
{
private const string ViewExtension = ".cshtml";
internal const string ControllerKey = "controller";
internal const string AreaKey = "area";
private static readonly string[] _viewLocationFormats =
private static readonly IEnumerable<string> _viewLocationFormats = new[]
{
"/Views/{1}/{0}" + ViewExtension,
"/Views/Shared/{0}" + ViewExtension,
};
private static readonly string[] _areaViewLocationFormats =
private static readonly IEnumerable<string> _areaViewLocationFormats = new[]
{
"/Areas/{2}/Views/{1}/{0}" + ViewExtension,
"/Areas/{2}/Views/Shared/{0}" + ViewExtension,
@ -31,27 +33,44 @@ namespace Microsoft.AspNet.Mvc.Razor
};
private readonly IRazorPageFactory _pageFactory;
private readonly IReadOnlyList<IViewLocationExpander> _viewLocationExpanders;
private readonly IViewLocationCache _viewLocationCache;
/// <summary>
/// Initializes a new instance of the <see cref="RazorViewEngine" /> class.
/// </summary>
/// <param name="pageFactory">The page factory used for creating <see cref="IRazorPage"/> instances.</param>
public RazorViewEngine(IRazorPageFactory pageFactory)
public RazorViewEngine(IRazorPageFactory pageFactory,
IViewLocationExpanderProvider viewLocationExpanderProvider,
IViewLocationCache viewLocationCache)
{
_pageFactory = pageFactory;
_viewLocationExpanders = viewLocationExpanderProvider.ViewLocationExpanders;
_viewLocationCache = viewLocationCache;
}
public IEnumerable<string> ViewLocationFormats
/// <summary>
/// Gets the locations where this instance of <see cref="RazorViewEngine"/> will search for views.
/// </summary>
public virtual IEnumerable<string> ViewLocationFormats
{
get { return _viewLocationFormats; }
}
/// <summary>
/// Gets the locations where this instance of <see cref="RazorViewEngine"/> will search for views within an
/// area.
/// </summary>
public virtual IEnumerable<string> AreaViewLocationFormats
{
get { return _areaViewLocationFormats; }
}
/// <inheritdoc />
public ViewEngineResult FindView([NotNull] ActionContext context,
[NotNull] string viewName)
{
var viewEngineResult = CreateViewEngineResult(context, viewName, partial: false);
return viewEngineResult;
return CreateViewEngineResult(context, viewName, partial: false);
}
/// <inheritdoc />
@ -81,22 +100,77 @@ namespace Microsoft.AspNet.Mvc.Razor
}
else
{
var routeValues = context.RouteData.Values;
var controllerName = routeValues.GetValueOrDefault<string>("controller");
var areaName = routeValues.GetValueOrDefault<string>("area");
var potentialPaths = GetViewSearchPaths(viewName, controllerName, areaName);
return LocateViewFromViewLocations(context, viewName, partial);
}
}
foreach (var path in potentialPaths)
private ViewEngineResult LocateViewFromViewLocations(ActionContext context,
string viewName,
bool partial)
{
// Initialize the dictionary for the typical case of having controller and action tokens.
var routeValues = context.RouteData.Values;
var areaName = routeValues.GetValueOrDefault<string>(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, viewName);
if (_viewLocationExpanders.Count > 0)
{
expanderContext.Values = new Dictionary<string, string>(StringComparer.Ordinal);
// 1. Populate values from viewLocationExpanders.
foreach (var expander in _viewLocationExpanders)
{
var page = _pageFactory.CreateInstance(path);
if (page != null)
{
return CreateFoundResult(context, page, path, partial);
}
expander.PopulateValues(expanderContext);
}
}
// 2. With the values that we've accumumlated so far, check if we have a cached result.
var viewLocation = _viewLocationCache.Get(expanderContext);
if (!string.IsNullOrEmpty(viewLocation))
{
var page = _pageFactory.CreateInstance(viewLocation);
if (page != null)
{
// 2a. We found a IRazorPage at the cached location.
return CreateFoundResult(context, page, viewName, partial);
}
}
// 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);
}
// 3. Use the expanded locations to look up a page.
var controllerName = routeValues.GetValueOrDefault<string>(ControllerKey);
var searchedLocations = new List<string>();
foreach (var path in viewLocations)
{
var transformedPath = string.Format(CultureInfo.InvariantCulture,
path,
viewName,
controllerName,
areaName);
var page = _pageFactory.CreateInstance(transformedPath);
if (page != null)
{
// 3a. We found a page. Cache the set of values that produced it and return a found result.
_viewLocationCache.Set(expanderContext, transformedPath);
return CreateFoundResult(context, page, transformedPath, partial);
}
return ViewEngineResult.NotFound(viewName, potentialPaths);
searchedLocations.Add(transformedPath);
}
// 3b. We did not find a page for any of the paths.
return ViewEngineResult.NotFound(viewName, searchedLocations);
}
private ViewEngineResult CreateFoundResult(ActionContext actionContext,
@ -117,26 +191,5 @@ namespace Microsoft.AspNet.Mvc.Razor
{
return name[0] == '~' || name[0] == '/';
}
private IEnumerable<string> GetViewSearchPaths(string viewName, string controllerName, string areaName)
{
IEnumerable<string> unformattedPaths;
if (string.IsNullOrEmpty(areaName))
{
// If no areas then no need to search area locations.
unformattedPaths = _viewLocationFormats;
}
else
{
// If there's an area provided only search area view locations
unformattedPaths = _areaViewLocationFormats;
}
var formattedPaths = unformattedPaths.Select(path =>
string.Format(CultureInfo.InvariantCulture, path, viewName, controllerName, areaName));
return formattedPaths;
}
}
}

View File

@ -165,6 +165,9 @@
<data name="ViewContextMustBeSet" xml:space="preserve">
<value>'{0} must be set to access '{1}'.</value>
</data>
<data name="ViewLocationCache_KeyMustBeString" xml:space="preserve">
<value>'{0}' must be a {1} that is generated as result of the call to '{2}'.</value>
</data>
<data name="ViewMustBeContextualized" xml:space="preserve">
<value>The '{0}' method must be called before '{1}' can be invoked.</value>
</data>

View File

@ -0,0 +1,36 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
namespace Microsoft.AspNet.Mvc.Razor
{
/// <summary>
/// A context for containing information for <see cref="IViewLocationExpander"/>.
/// </summary>
public class ViewLocationExpanderContext
{
public ViewLocationExpanderContext([NotNull] ActionContext actionContext,
[NotNull] string viewName)
{
ActionContext = actionContext;
ViewName = viewName;
}
/// <summary>
/// Gets the <see cref="ActionContext"/> for the current executing action.
/// </summary>
public ActionContext ActionContext { get; private set; }
/// <summary>
/// Gets the view name
/// </summary>
public string ViewName { get; private set; }
/// <summary>
/// Gets or sets the <see cref="IDictionary{TKey, TValue}"/> that is populated with values as part of
/// <see cref="IViewLocationExpander.PopulateValues(ViewLocationExpanderContext)"/>.
/// </summary>
public IDictionary<string, string> Values { get; set; }
}
}

View File

@ -3,6 +3,7 @@
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Razor;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.Framework.OptionsModel;
namespace Microsoft.AspNet.Mvc

View File

@ -10,6 +10,7 @@ using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.OptionDescriptors;
using Microsoft.AspNet.Mvc.Razor;
using Microsoft.AspNet.Mvc.Razor.Compilation;
using Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Mvc.Routing;
using Microsoft.AspNet.Security;
@ -53,6 +54,11 @@ namespace Microsoft.AspNet.Mvc
yield return describe.Singleton<IViewStartProvider, ViewStartProvider>();
yield return describe.Transient<IRazorView, RazorView>();
// Transient since the IViewLocationExpanders returned by the instance is cached by view engines.
yield return describe.Transient<IViewLocationExpanderProvider, DefaultViewLocationExpanderProvider>();
// Caches view locations that are valid for the lifetime of the application.
yield return describe.Singleton<IViewLocationCache, DefaultViewLocationCache>();
yield return describe.Singleton<IRazorPageActivator, RazorPageActivator>();
// Virtual path view factory needs to stay scoped so views can get get scoped services.
yield return describe.Scoped<IRazorPageFactory, VirtualPathRazorPageFactory>();

View File

@ -101,5 +101,42 @@ component-content";
// Assert
Assert.Equal(expected, body.Trim());
}
public static IEnumerable<object[]> RazorViewEngine_UsesAllExpandedPathsToLookForViewsData
{
get
{
var expected1 = string.Join(Environment.NewLine,
"expander-index",
"gb-partial");
yield return new[] { "gb", expected1 };
var expected2 = string.Join(Environment.NewLine,
"fr-index",
"fr-partial");
yield return new[] { "fr", expected2 };
var expected3 = string.Join(Environment.NewLine,
"expander-index",
"expander-partial");
yield return new[] { "na", expected3 };
}
}
[Theory]
[MemberData(nameof(RazorViewEngine_UsesAllExpandedPathsToLookForViewsData))]
public async Task RazorViewEngine_UsesViewExpandersForViewsAndPartials(string value, string expected)
{
// Arrange
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
// Act
var body = await client.GetStringAsync("http://localhost/TemplateExpander?language-expander-value=" +
value);
// Assert
Assert.Equal(expected, body.Trim());
}
}
}

View File

@ -10,7 +10,7 @@ using Microsoft.Framework.Runtime;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.Core.Test
namespace Microsoft.AspNet.Mvc.Razor
{
public class ExpiringFileInfoCacheTest
{
@ -27,28 +27,28 @@ namespace Microsoft.AspNet.Mvc.Core.Test
}
}
public MvcOptions Options
public RazorViewEngineOptions Options
{
get
{
return new MvcOptions();
return new RazorViewEngineOptions();
}
}
public IOptionsAccessor<MvcOptions> OptionsAccessor
public IOptionsAccessor<RazorViewEngineOptions> OptionsAccessor
{
get
{
var options = Options;
var mock = new Mock<IOptionsAccessor<MvcOptions>>(MockBehavior.Strict);
var mock = new Mock<IOptionsAccessor<RazorViewEngineOptions>>(MockBehavior.Strict);
mock.Setup(oa => oa.Options).Returns(options);
return mock.Object;
}
}
public ControllableExpiringFileInfoCache GetCache(IOptionsAccessor<MvcOptions> optionsAccessor)
public ControllableExpiringFileInfoCache GetCache(IOptionsAccessor<RazorViewEngineOptions> optionsAccessor)
{
return new ControllableExpiringFileInfoCache(ApplicationEnvironment, optionsAccessor);
}
@ -69,16 +69,16 @@ namespace Microsoft.AspNet.Mvc.Core.Test
cache.Sleep(offsetMilliseconds);
}
public void Sleep(IOptionsAccessor<MvcOptions> accessor, ControllableExpiringFileInfoCache cache, int offsetMilliSeconds)
public void Sleep(IOptionsAccessor<RazorViewEngineOptions> accessor, ControllableExpiringFileInfoCache cache, int offsetMilliSeconds)
{
var baseMilliSeconds = (int)accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds;
var baseMilliSeconds = (int)accessor.Options.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds;
cache.Sleep(baseMilliSeconds + offsetMilliSeconds);
}
public void SetExpiration(IOptionsAccessor<MvcOptions> accessor, TimeSpan expiration)
public void SetExpiration(IOptionsAccessor<RazorViewEngineOptions> accessor, TimeSpan expiration)
{
accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk = expiration;
accessor.Options.ExpirationBeforeCheckingFilesOnDisk = expiration;
}
[Fact]
@ -87,7 +87,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test
var optionsAccessor = OptionsAccessor;
// Assert
Assert.Equal(2000, optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds);
Assert.Equal(2000, optionsAccessor.Options.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds);
}
[Fact]
@ -323,7 +323,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test
public class ControllableExpiringFileInfoCache : ExpiringFileInfoCache
{
public ControllableExpiringFileInfoCache(IApplicationEnvironment env,
IOptionsAccessor<MvcOptions> optionsAccessor)
IOptionsAccessor<RazorViewEngineOptions> optionsAccessor)
: base(env, optionsAccessor)
{
}

View File

@ -0,0 +1,131 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.PipelineCore;
using Microsoft.AspNet.Routing;
using Xunit;
namespace Microsoft.AspNet.Mvc.Razor
{
public class DefaultViewLocationCacheTest
{
public static IEnumerable<object[]> CacheEntryData
{
get
{
yield return new[] { new ViewLocationExpanderContext(GetActionContext(), "test") };
var areaActionContext = GetActionContext("controller2", "myarea");
yield return new[] { new ViewLocationExpanderContext(areaActionContext, "test2") };
var actionContext = GetActionContext("controller3", "area3");
var values = new Dictionary<string, string>(StringComparer.Ordinal)
{
{ "culture", "fr" },
{ "theme", "sleek" }
};
var expanderContext = new ViewLocationExpanderContext(actionContext, "test3")
{
Values = values
};
yield return new [] { expanderContext };
}
}
[Theory]
[MemberData(nameof(CacheEntryData))]
public void Get_GeneratesCacheKeyIfItemDoesNotExist(ViewLocationExpanderContext context)
{
// Arrange
var cache = new DefaultViewLocationCache();
// Act
var result = cache.Get(context);
// Assert
Assert.Null(result);
}
[Theory]
[MemberData(nameof(CacheEntryData))]
public void InvokingGetAfterSet_ReturnsCachedItem(ViewLocationExpanderContext context)
{
// Arrange
var cache = new DefaultViewLocationCache();
var value = Guid.NewGuid().ToString();
// Act
cache.Set(context, value);
var result = cache.Get(context);
// Assert
Assert.Equal(value, result);
}
public static IEnumerable<object[]> CacheKeyData
{
get
{
yield return new object[]
{
new ViewLocationExpanderContext(GetActionContext(), "test"),
"test:mycontroller"
};
var areaActionContext = GetActionContext("controller2", "myarea");
yield return new object[]
{
new ViewLocationExpanderContext(areaActionContext, "test2"),
"test2:controller2:myarea"
};
var actionContext = GetActionContext("controller3", "area3");
var values = new Dictionary<string, string>(StringComparer.Ordinal)
{
{ "culture", "fr" },
{ "theme", "sleek" }
};
var expanderContext = new ViewLocationExpanderContext(actionContext, "test3")
{
Values = values
};
yield return new object[]
{
expanderContext,
"test3:controller3:area3:culture:fr:theme:sleek"
};
}
}
[Theory]
[MemberData(nameof(CacheKeyData))]
public void CacheKeyIsComputedBasedOnValuesInExpander(ViewLocationExpanderContext context, string expected)
{
// Act
var result = DefaultViewLocationCache.GenerateKey(context);
// Assert
Assert.Equal(expected, result);
}
public static ActionContext GetActionContext(string controller = "mycontroller",
string area = null)
{
var routeData = new RouteData
{
Values = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
};
routeData.Values["controller"] = controller;
if (area != null)
{
routeData.Values["area"] = area;
}
return new ActionContext(new DefaultHttpContext(), routeData, new ActionDescriptor());
}
}
}

View File

@ -0,0 +1,71 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.Framework.DependencyInjection;
using Microsoft.Framework.OptionsModel;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors
{
public class DefaultViewLocationExpanderProviderTest
{
[Fact]
public void ViewLocationExpanders_ReturnsActivatedListOfExpanders()
{
// Arrange
var service = Mock.Of<ITestService>();
var expander = Mock.Of<IViewLocationExpander>();
var type = typeof(TestViewLocationExpander);
var typeActivator = new TypeActivator();
var serviceProvider = new Mock<IServiceProvider>();
serviceProvider.Setup(p => p.GetService(typeof(ITestService)))
.Returns(service);
var options = new RazorViewEngineOptions();
options.ViewLocationExpanders.Add(type);
options.ViewLocationExpanders.Add(expander);
var accessor = new Mock<IOptionsAccessor<RazorViewEngineOptions>>();
accessor.SetupGet(a => a.Options)
.Returns(options);
var provider = new DefaultViewLocationExpanderProvider(accessor.Object,
typeActivator,
serviceProvider.Object);
// Act
var result = provider.ViewLocationExpanders;
// Assert
Assert.Equal(2, result.Count);
var testExpander = Assert.IsType<TestViewLocationExpander>(result[0]);
Assert.Same(service, testExpander.Service);
Assert.Same(expander, result[1]);
}
private class TestViewLocationExpander : IViewLocationExpander
{
public TestViewLocationExpander(ITestService service)
{
Service = service;
}
public ITestService Service { get; private set; }
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
throw new NotImplementedException();
}
public void PopulateValues(ViewLocationExpanderContext context)
{
throw new NotImplementedException();
}
}
public interface ITestService
{
}
}
}

View File

@ -0,0 +1,80 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.Razor
{
public class ViewLocationExpanderDescriptorExtensionsTest
{
[Theory]
[InlineData(-1)]
[InlineData(5)]
public void Insert_WithType_ThrowsIfIndexIsOutOfBounds(int index)
{
// Arrange
var collection = new List<ViewLocationExpanderDescriptor>
{
new ViewLocationExpanderDescriptor(Mock.Of<IViewLocationExpander>()),
new ViewLocationExpanderDescriptor(Mock.Of<IViewLocationExpander>())
};
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>("index",
() => collection.Insert(index, typeof(IViewLocationExpander)));
}
[Theory]
[InlineData(-2)]
[InlineData(3)]
public void Insert_WithInstance_ThrowsIfIndexIsOutOfBounds(int index)
{
// Arrange
var collection = new List<ViewLocationExpanderDescriptor>
{
new ViewLocationExpanderDescriptor(Mock.Of<IViewLocationExpander>()),
new ViewLocationExpanderDescriptor(Mock.Of<IViewLocationExpander>())
};
var expander = Mock.Of<IViewLocationExpander>();
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>("index", () => collection.Insert(index, expander));
}
[InlineData]
public void ViewLocationExpanderDescriptors_AddsTypesAndInstances()
{
// Arrange
var expander = Mock.Of<IViewLocationExpander>();
var type = typeof(TestViewLocationExpander);
var collection = new List<ViewLocationExpanderDescriptor>();
// Act
collection.Add(expander);
collection.Insert(0, type);
// Assert
Assert.Equal(2, collection.Count);
Assert.IsType<TestViewLocationExpander>(collection[0].Instance);
Assert.Same(expander, collection[0].Instance);
}
private class TestViewLocationExpander : IViewLocationExpander
{
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
throw new NotImplementedException();
}
public void PopulateValues(ViewLocationExpanderContext context)
{
throw new NotImplementedException();
}
}
}
}

View File

@ -0,0 +1,68 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Testing;
using Xunit;
namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors
{
public class ViewLocationExpanderDescriptorTest
{
[Fact]
public void ConstructorThrows_IfTypeIsNotViewLocationExpander()
{
// Arrange
var viewEngineType = typeof(IViewLocationExpander).FullName;
var type = typeof(string);
var expected = string.Format("The type '{0}' must derive from '{1}'.",
type.FullName, viewEngineType);
// Act & Assert
ExceptionAssert.ThrowsArgument(() => new ViewLocationExpanderDescriptor(type), "type", expected);
}
[Fact]
public void ConstructorSetsViewLocationExpanderType()
{
// Arrange
var type = typeof(TestViewLocationExpander);
// Act
var descriptor = new ViewLocationExpanderDescriptor(type);
// Assert
Assert.Equal(type, descriptor.OptionType);
Assert.Null(descriptor.Instance);
}
[Fact]
public void ConstructorSetsViewLocationExpanderAndType()
{
// Arrange
var expander = new TestViewLocationExpander();
// Act
var descriptor = new ViewLocationExpanderDescriptor(expander);
// Assert
Assert.Same(expander, descriptor.Instance);
Assert.Equal(expander.GetType(), descriptor.OptionType);
}
private class TestViewLocationExpander : IViewLocationExpander
{
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
throw new NotImplementedException();
}
public void PopulateValues(ViewLocationExpanderContext context)
{
throw new NotImplementedException();
}
}
}
}

View File

@ -3,6 +3,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.PipelineCore;
using Microsoft.AspNet.Routing;
@ -40,7 +42,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindView_WithFullPathReturnsNotFound_WhenPathDoesNotMatchExtension(string viewName)
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_controllerTestContext);
// Act
@ -55,7 +57,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindViewFullPathSucceedsWithCshtmlEnding(string viewName)
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
// Append .cshtml so the viewname is no longer invalid
viewName += ".cshtml";
var context = GetActionContext(_controllerTestContext);
@ -72,7 +74,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindPartialView_WithFullPathReturnsNotFound_WhenPathDoesNotMatchExtension(string partialViewName)
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_controllerTestContext);
// Act
@ -87,7 +89,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindPartialViewFullPathSucceedsWithCshtmlEnding(string partialViewName)
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
// Append .cshtml so the viewname is no longer invalid
partialViewName += ".cshtml";
var context = GetActionContext(_controllerTestContext);
@ -104,7 +106,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
{
// Arrange
var searchedLocations = new List<string>();
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_areaTestContext);
// Act
@ -112,7 +114,8 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
// Assert
Assert.False(result.Success);
Assert.Equal(new[] {
Assert.Equal(new[]
{
"/Areas/foo/Views/bar/partial.cshtml",
"/Areas/foo/Views/Shared/partial.cshtml",
"/Views/Shared/partial.cshtml",
@ -123,7 +126,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindPartialViewFailureSearchesCorrectLocationsWithoutAreas()
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_controllerTestContext);
// Act
@ -141,7 +144,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindViewFailureSearchesCorrectLocationsWithAreas()
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_areaTestContext);
// Act
@ -160,7 +163,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
public void FindViewFailureSearchesCorrectLocationsWithoutAreas()
{
// Arrange
var viewEngine = CreateSearchLocationViewEngineTester();
var viewEngine = CreateViewEngine();
var context = GetActionContext(_controllerTestContext);
// Act
@ -182,7 +185,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
var page = Mock.Of<IRazorPage>();
pageFactory.Setup(p => p.CreateInstance(It.IsAny<string>()))
.Returns(Mock.Of<IRazorPage>());
var viewEngine = new RazorViewEngine(pageFactory.Object);
var viewEngine = CreateViewEngine(pageFactory.Object);
var context = GetActionContext(_controllerTestContext);
// Act
@ -194,17 +197,261 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
Assert.Equal("/Views/bar/test-view.cshtml", result.ViewName);
}
private IViewEngine CreateSearchLocationViewEngineTester()
[Fact]
public void FindView_UsesViewLocationFormat_IfRouteDoesNotContainArea()
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>();
pageFactory.Setup(vpf => vpf.CreateInstance(It.IsAny<string>()))
.Returns<RazorPage>(null);
var page = Mock.Of<IRazorPage>();
pageFactory.Setup(p => p.CreateInstance("fake-path1/bar/test-view.rzr"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var viewEngine = new OverloadedLocationViewEngine(pageFactory.Object,
GetViewLocationExpanders(),
GetViewLocationCache());
var context = GetActionContext(_controllerTestContext);
var viewEngine = new RazorViewEngine(pageFactory.Object);
// Act
var result = viewEngine.FindView(context, "test-view");
// Assert
pageFactory.Verify();
}
[Fact]
public void FindView_UsesAreaViewLocationFormat_IfRouteContainsArea()
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>();
var page = Mock.Of<IRazorPage>();
pageFactory.Setup(p => p.CreateInstance("fake-area-path/foo/bar/test-view2.rzr"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var viewEngine = new OverloadedLocationViewEngine(pageFactory.Object,
GetViewLocationExpanders(),
GetViewLocationCache());
var context = GetActionContext(_areaTestContext);
// Act
var result = viewEngine.FindView(context, "test-view2");
// Assert
pageFactory.Verify();
}
public static IEnumerable<object[]> FindView_UsesViewLocationExpandersToLocateViewsData
{
get
{
yield return new object[]
{
_controllerTestContext,
new[]
{
"/Views/{1}/{0}.cshtml",
"/Views/Shared/{0}.cshtml"
}
};
yield return new object[]
{
_areaTestContext,
new[]
{
"/Areas/{2}/Views/{1}/{0}.cshtml",
"/Areas/{2}/Views/Shared/{0}.cshtml",
"/Views/Shared/{0}.cshtml"
}
};
}
}
[Theory]
[MemberData(nameof(FindView_UsesViewLocationExpandersToLocateViewsData))]
public void FindView_UsesViewLocationExpandersToLocateViews(IDictionary<string, object> routeValues,
IEnumerable<string> expectedSeeds)
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>();
pageFactory.Setup(p => p.CreateInstance("test-string/bar.cshtml"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var expander1Result = new[] { "some-seed" };
var expander1 = new Mock<IViewLocationExpander>();
expander1.Setup(e => e.PopulateValues(It.IsAny<ViewLocationExpanderContext>()))
.Callback((ViewLocationExpanderContext c) =>
{
Assert.NotNull(c.ActionContext);
c.Values["expander-key"] = expander1.ToString();
})
.Verifiable();
expander1.Setup(e => e.ExpandViewLocations(It.IsAny<ViewLocationExpanderContext>(),
It.IsAny<IEnumerable<string>>()))
.Callback((ViewLocationExpanderContext c, IEnumerable<string> seeds) =>
{
Assert.NotNull(c.ActionContext);
Assert.Equal(expectedSeeds, seeds);
})
.Returns(expander1Result)
.Verifiable();
var expander2 = new Mock<IViewLocationExpander>();
expander2.Setup(e => e.ExpandViewLocations(It.IsAny<ViewLocationExpanderContext>(),
It.IsAny<IEnumerable<string>>()))
.Callback((ViewLocationExpanderContext c, IEnumerable<string> seeds) =>
{
Assert.Equal(expander1Result, seeds);
})
.Returns(new[] { "test-string/{1}.cshtml" })
.Verifiable();
var viewEngine = CreateViewEngine(pageFactory.Object,
new[] { expander1.Object, expander2.Object });
var context = GetActionContext(routeValues);
// Act
var result = viewEngine.FindView(context, "test-view");
// Assert
Assert.True(result.Success);
Assert.IsAssignableFrom<IRazorView>(result.View);
pageFactory.Verify();
expander1.Verify();
expander2.Verify();
}
[Fact]
public void FindView_CachesValuesIfViewWasFound()
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>();
pageFactory.Setup(p => p.CreateInstance("/Views/bar/baz.cshtml"))
.Verifiable();
pageFactory.Setup(p => p.CreateInstance("/Views/Shared/baz.cshtml"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var cache = GetViewLocationCache();
var cacheMock = Mock.Get<IViewLocationCache>(cache);
cacheMock.Setup(c => c.Set(It.IsAny<ViewLocationExpanderContext>(), "/Views/Shared/baz.cshtml"))
.Verifiable();
var viewEngine = CreateViewEngine(pageFactory.Object, cache: cache);
var context = GetActionContext(_controllerTestContext);
// Act
var result = viewEngine.FindView(context, "baz");
// Assert
Assert.True(result.Success);
pageFactory.Verify();
cacheMock.Verify();
}
[Fact]
public void FindView_UsesCachedValueIfViewWasFound()
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>(MockBehavior.Strict);
pageFactory.Setup(p => p.CreateInstance("some-view-location"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var expander = new Mock<IViewLocationExpander>(MockBehavior.Strict);
expander.Setup(v => v.PopulateValues(It.IsAny<ViewLocationExpanderContext>()))
.Verifiable();
var cacheMock = new Mock<IViewLocationCache>();
cacheMock.Setup(c => c.Get(It.IsAny<ViewLocationExpanderContext>()))
.Returns("some-view-location")
.Verifiable();
var viewEngine = CreateViewEngine(pageFactory.Object,
new[] { expander.Object },
cacheMock.Object);
var context = GetActionContext(_controllerTestContext);
// Act
var result = viewEngine.FindView(context, "baz");
// Assert
Assert.True(result.Success);
pageFactory.Verify();
cacheMock.Verify();
expander.Verify();
}
[Fact]
public void FindView_LooksForViewsIfCachedViewDoesNotExist()
{
// Arrange
var pageFactory = new Mock<IRazorPageFactory>();
pageFactory.Setup(p => p.CreateInstance("expired-location"))
.Returns((IRazorPage)null)
.Verifiable();
pageFactory.Setup(p => p.CreateInstance("some-view-location"))
.Returns(Mock.Of<IRazorPage>())
.Verifiable();
var cacheMock = new Mock<IViewLocationCache>();
cacheMock.Setup(c => c.Get(It.IsAny<ViewLocationExpanderContext>()))
.Returns("expired-location");
var expander = new Mock<IViewLocationExpander>();
expander.Setup(v => v.PopulateValues(It.IsAny<ViewLocationExpanderContext>()))
.Verifiable();
var expanderResult = new[] { "some-view-location" };
expander.Setup(v => v.ExpandViewLocations(
It.IsAny<ViewLocationExpanderContext>(), It.IsAny<IEnumerable<string>>()))
.Returns((ViewLocationExpanderContext c, IEnumerable<string> seed) => expanderResult)
.Verifiable();
var viewEngine = CreateViewEngine(pageFactory.Object,
new[] { expander.Object },
cacheMock.Object);
var context = GetActionContext(_controllerTestContext);
// Act
var result = viewEngine.FindView(context, "baz");
// Assert
Assert.True(result.Success);
pageFactory.Verify();
cacheMock.Verify();
expander.Verify();
}
private IViewEngine CreateViewEngine(IRazorPageFactory pageFactory = null,
IEnumerable<IViewLocationExpander> expanders = null,
IViewLocationCache cache = null)
{
pageFactory = pageFactory ?? Mock.Of<IRazorPageFactory>();
cache = cache ?? GetViewLocationCache();
var viewLocationExpanderProvider = GetViewLocationExpanders(expanders);
var viewEngine = new RazorViewEngine(pageFactory,
viewLocationExpanderProvider,
cache);
return viewEngine;
}
private static IViewLocationExpanderProvider GetViewLocationExpanders(
IEnumerable<IViewLocationExpander> expanders = null)
{
expanders = expanders ?? Enumerable.Empty<IViewLocationExpander>();
var viewLocationExpander = new Mock<IViewLocationExpanderProvider>();
viewLocationExpander.Setup(v => v.ViewLocationExpanders)
.Returns(expanders.ToList());
return viewLocationExpander.Object;
}
private static IViewLocationCache GetViewLocationCache()
{
var cacheMock = new Mock<IViewLocationCache>();
cacheMock.Setup(c => c.Get(It.IsAny<ViewLocationExpanderContext>()))
.Returns<string>(null);
return cacheMock.Object;
}
private static ActionContext GetActionContext(IDictionary<string, object> routeValues,
IRazorView razorView = null)
{
@ -217,5 +464,31 @@ namespace Microsoft.AspNet.Mvc.Razor.Test
var routeData = new RouteData { Values = routeValues };
return new ActionContext(httpContext, routeData, new ActionDescriptor());
}
private class OverloadedLocationViewEngine : RazorViewEngine
{
public OverloadedLocationViewEngine(IRazorPageFactory pageFactory,
IViewLocationExpanderProvider expanderProvider,
IViewLocationCache cache)
: base(pageFactory, expanderProvider, cache)
{
}
public override IEnumerable<string> ViewLocationFormats
{
get
{
return new[] { "fake-path1/{1}/{0}.rzr" };
}
}
public override IEnumerable<string> AreaViewLocationFormats
{
get
{
return new[] { "fake-area-path/{2}/{1}/{0}.rzr" };
}
}
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNet.Mvc;
namespace RazorWebSite.Controllers
{
public class TemplateExpander : Controller
{
public ViewResult Index()
{
return View();
}
}
}

View File

@ -0,0 +1,69 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.Razor;
namespace RazorWebSite
{
/// <summary>
/// A <see cref="IViewLocationExpander"/> that replaces adds the language as an extension prefix to view names.
/// </summary>
/// <example>
/// For the default case with no areas, views are generated with the following patterns (assuming controller is
/// "Home", action is "Index" and language is "en")
/// Views/Home/en/Action
/// Views/Home/Action
/// Views/Shared/en/Action
/// Views/Shared/Action
/// </example>
public class LanguageViewLocationExpander : IViewLocationExpander
{
private const string ValueKey = "language";
private readonly Func<ActionContext, string> _valueFactory;
/// <summary>
/// Initializes a new instance of <see cref="LanguageViewLocationExpander"/>.
/// </summary>
/// <param name="valueFactory">A factory that provides tbe language to use for expansion.</param>
public LanguageViewLocationExpander(Func<ActionContext, string> valueFactory)
{
_valueFactory = valueFactory;
}
/// <inheritdoc />
public void PopulateValues(ViewLocationExpanderContext context)
{
var value = _valueFactory(context.ActionContext);
if (!string.IsNullOrEmpty(value))
{
context.Values[ValueKey] = value;
}
}
/// <inheritdoc />
public virtual IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
string value;
if (context.Values.TryGetValue(ValueKey, out value))
{
return ExpandViewLocationsCore(viewLocations, value);
}
return viewLocations;
}
private IEnumerable<string> ExpandViewLocationsCore(IEnumerable<string> viewLocations,
string value)
{
foreach (var location in viewLocations)
{
yield return location.Replace("{0}", value + "/{0}");
yield return location;
}
}
}
}

View File

@ -1,4 +1,6 @@
using Microsoft.AspNet.Builder;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.Razor;
using Microsoft.Framework.DependencyInjection;
namespace RazorWebSite
@ -15,6 +17,12 @@ namespace RazorWebSite
// Add MVC services to the services container
services.AddMvc(configuration);
services.AddTransient<InjectedHelper>();
services.SetupOptions<RazorViewEngineOptions>(options =>
{
var expander = new LanguageViewLocationExpander(
context => context.HttpContext.Request.Query["language-expander-value"]);
options.ViewLocationExpanders.Add(expander);
});
});
// Add MVC to the request pipeline

View File

@ -0,0 +1,2 @@
expander-index
@await Html.PartialAsync("_Partial")

View File

@ -0,0 +1 @@
expander-partial

View File

@ -0,0 +1,2 @@
fr-index
@await Html.PartialAsync("_Partial")

View File

@ -0,0 +1 @@
fr-partial

View File

@ -0,0 +1 @@
gb-partial