[Fixes #4550] Add an Option to set the Razor view location formats

This commit is contained in:
Kiran Challa 2016-04-28 11:31:21 -07:00
parent 1de268d6a7
commit f7b2ee80fd
8 changed files with 211 additions and 119 deletions

View File

@ -462,6 +462,22 @@ namespace Microsoft.AspNetCore.Mvc.Razor
return string.Format(CultureInfo.CurrentCulture, GetString("Compilation_DependencyContextIsNotSpecified"), p0, p1, p2);
}
/// <summary>
/// '{0}' cannot be empty. These locations are required to locate a view for rendering.
/// </summary>
internal static string ViewLocationFormatsIsRequired
{
get { return GetString("ViewLocationFormatsIsRequired"); }
}
/// <summary>
/// '{0}' cannot be empty. These locations are required to locate a view for rendering.
/// </summary>
internal static string FormatViewLocationFormatsIsRequired(object p0)
{
return string.Format(CultureInfo.CurrentCulture, GetString("ViewLocationFormatsIsRequired"), p0);
}
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -8,7 +8,6 @@ using System.Globalization;
using System.Linq;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Razor.Internal;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@ -21,12 +20,15 @@ namespace Microsoft.AspNetCore.Mvc.Razor
/// Default implementation of <see cref="IRazorViewEngine"/>.
/// </summary>
/// <remarks>
/// For <c>ViewResults</c> returned from controllers, views should be located in <see cref="ViewLocationFormats"/>
/// by default. For the controllers in an area, views should exist in <see cref="AreaViewLocationFormats"/>.
/// For <c>ViewResults</c> returned from controllers, views should be located in
/// <see cref="RazorViewEngineOptions.ViewLocationFormats"/>
/// by default. For the controllers in an area, views should exist in
/// <see cref="RazorViewEngineOptions.AreaViewLocationFormats"/>.
/// </remarks>
public class RazorViewEngine : IRazorViewEngine
{
private const string ViewExtension = ".cshtml";
public static readonly string ViewExtension = ".cshtml";
private const string ControllerKey = "controller";
private const string AreaKey = "area";
private static readonly ViewLocationCacheItem[] EmptyViewStartLocationCacheItems =
@ -34,10 +36,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor
private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20);
private readonly IRazorPageFactoryProvider _pageFactory;
private readonly IList<IViewLocationExpander> _viewLocationExpanders;
private readonly IRazorPageActivator _pageActivator;
private readonly HtmlEncoder _htmlEncoder;
private readonly ILogger _logger;
private readonly RazorViewEngineOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="RazorViewEngine" />.
@ -49,9 +51,24 @@ namespace Microsoft.AspNetCore.Mvc.Razor
IOptions<RazorViewEngineOptions> optionsAccessor,
ILoggerFactory loggerFactory)
{
_options = optionsAccessor.Value;
if (_options.ViewLocationFormats.Count == 0)
{
throw new ArgumentException(
Resources.FormatViewLocationFormatsIsRequired(nameof(RazorViewEngineOptions.ViewLocationFormats)),
nameof(optionsAccessor));
}
if (_options.AreaViewLocationFormats.Count == 0)
{
throw new ArgumentException(
Resources.FormatViewLocationFormatsIsRequired(nameof(RazorViewEngineOptions.AreaViewLocationFormats)),
nameof(optionsAccessor));
}
_pageFactory = pageFactory;
_pageActivator = pageActivator;
_viewLocationExpanders = optionsAccessor.Value.ViewLocationExpanders;
_htmlEncoder = htmlEncoder;
_logger = loggerFactory.CreateLogger<RazorViewEngine>();
ViewLookupCache = new MemoryCache(new MemoryCacheOptions
@ -60,47 +77,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor
});
}
/// <summary>
/// Gets the locations where this instance of <see cref="RazorViewEngine"/> will search for views.
/// </summary>
/// <remarks>
/// 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-sensitive file systems.
/// For example, the view for the <c>Test</c> action of <c>HomeController</c> should be located at
/// <c>/Views/Home/Test.cshtml</c>. Locations such as <c>/views/home/test.cshtml</c> would not be discovered
/// </remarks>
public virtual IEnumerable<string> ViewLocationFormats { get; } = new[]
{
"/Views/{1}/{0}" + ViewExtension,
"/Views/Shared/{0}" + ViewExtension,
};
/// <summary>
/// Gets the locations where this instance of <see cref="RazorViewEngine"/> will search for views within an
/// area.
/// </summary>
/// <remarks>
/// 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-sensitive file systems.
/// For example, the view for the <c>Test</c> action of <c>HomeController</c> should be located at
/// <c>/Views/Home/Test.cshtml</c>. Locations such as <c>/views/home/test.cshtml</c> would not be discovered
/// </remarks>
public virtual IEnumerable<string> AreaViewLocationFormats { get; } = new[]
{
"/Areas/{2}/Views/{1}/{0}" + ViewExtension,
"/Areas/{2}/Views/Shared/{0}" + ViewExtension,
"/Views/Shared/{0}" + ViewExtension,
};
/// <summary>
/// A cache for results of view lookups.
/// </summary>
@ -311,15 +287,15 @@ namespace Microsoft.AspNetCore.Mvc.Razor
isMainPage);
Dictionary<string, string> expanderValues = null;
if (_viewLocationExpanders.Count > 0)
if (_options.ViewLocationExpanders.Count > 0)
{
expanderValues = new Dictionary<string, string>(StringComparer.Ordinal);
expanderContext.Values = expanderValues;
// Perf: Avoid allocations
for (var i = 0; i < _viewLocationExpanders.Count; i++)
for (var i = 0; i < _options.ViewLocationExpanders.Count; i++)
{
_viewLocationExpanders[i].PopulateValues(expanderContext);
_options.ViewLocationExpanders[i].PopulateValues(expanderContext);
}
}
@ -385,13 +361,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor
ViewLocationCacheKey cacheKey)
{
// Only use the area view location formats if we have an area token.
var viewLocations = !string.IsNullOrEmpty(expanderContext.AreaName) ?
AreaViewLocationFormats :
ViewLocationFormats;
IEnumerable<string> viewLocations = !string.IsNullOrEmpty(expanderContext.AreaName) ?
_options.AreaViewLocationFormats :
_options.ViewLocationFormats;
for (var i = 0; i < _viewLocationExpanders.Count; i++)
for (var i = 0; i < _options.ViewLocationExpanders.Count; i++)
{
viewLocations = _viewLocationExpanders[i].ExpandViewLocations(expanderContext, viewLocations);
viewLocations = _options.ViewLocationExpanders[i].ExpandViewLocations(expanderContext, viewLocations);
}
ViewLocationCacheResult cacheResult = null;

View File

@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
private Action<RoslynCompilationContext> _compilationCallback = c => { };
/// <summary>
/// Get a <see cref="IList{IViewLocationExpander}"/> used by the <see cref="RazorViewEngine"/>.
/// Gets a <see cref="IList{IViewLocationExpander}"/> used by the <see cref="RazorViewEngine"/>.
/// </summary>
public IList<IViewLocationExpander> ViewLocationExpanders { get; }
= new List<IViewLocationExpander>();
@ -35,6 +35,61 @@ namespace Microsoft.AspNetCore.Mvc.Razor
/// </remarks>
public IList<IFileProvider> FileProviders { get; } = new List<IFileProvider>();
/// <summary>
/// Gets the locations where <see cref="RazorViewEngine"/> will search for views.
/// </summary>
/// <remarks>
/// <para>
/// 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 may contain the following format items:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>{0} - Action Name</description>
/// </item>
/// <item>
/// <description>{1} - Controller Name</description>
/// </item>
/// </list>
/// <para>
/// The values for these locations are case-sensitive on case-sensitive file systems.
/// For example, the view for the <c>Test</c> action of <c>HomeController</c> should be located at
/// <c>/Views/Home/Test.cshtml</c>. Locations such as <c>/views/home/test.cshtml</c> would not be discovered.
/// </para>
/// </remarks>
public IList<string> ViewLocationFormats { get; } = new List<string>();
/// <summary>
/// Gets the locations where <see cref="RazorViewEngine"/> will search for views within an
/// area.
/// </summary>
/// <remarks>
/// <para>
/// 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 may contain the following format items:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>{0} - Action Name</description>
/// </item>
/// <item>
/// <description>{1} - Controller Name</description>
/// </item>
/// <item>
/// <description>{2} - Area Name</description>
/// </item>
/// </list>
/// <para>
/// The values for these locations are case-sensitive on case-sensitive file systems.
/// For example, the view for the <c>Test</c> action of <c>HomeController</c> under <c>Admin</c> area should
/// be located at <c>/Areas/Admin/Views/Home/Test.cshtml</c>.
/// Locations such as <c>/areas/admin/views/home/test.cshtml</c> would not be discovered.
/// </para>
/// </remarks>
public IList<string> AreaViewLocationFormats { get; } = new List<string>();
/// <summary>
/// Gets or sets the callback that is used to customize Razor compilation
/// to change compilation settings you can update <see cref="RoslynCompilationContext.Compilation"/> property.

View File

@ -50,6 +50,13 @@ namespace Microsoft.AspNetCore.Mvc
var parseOptions = razorOptions.ParseOptions;
razorOptions.ParseOptions = parseOptions.WithPreprocessorSymbols(
parseOptions.PreprocessorSymbolNames.Concat(new[] { configurationSymbol }));
razorOptions.ViewLocationFormats.Add("/Views/{1}/{0}" + RazorViewEngine.ViewExtension);
razorOptions.ViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension);
razorOptions.AreaViewLocationFormats.Add("/Areas/{2}/Views/{1}/{0}" + RazorViewEngine.ViewExtension);
razorOptions.AreaViewLocationFormats.Add("/Areas/{2}/Views/Shared/{0}" + RazorViewEngine.ViewExtension);
razorOptions.AreaViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension);
}
}
}

View File

@ -203,4 +203,7 @@
<data name="Compilation_DependencyContextIsNotSpecified" xml:space="preserve">
<value>The Razor page '{0}' failed to compile. Ensure that your application's {1} sets the '{2}' compilation property.</value>
</data>
<data name="ViewLocationFormatsIsRequired" xml:space="preserve">
<value>'{0}' cannot be empty. These locations are required to locate a view for rendering.</value>
</data>
</root>

View File

@ -1,6 +1,8 @@
// 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.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.DependencyInjection;
@ -11,6 +13,67 @@ namespace Microsoft.AspNetCore.Mvc.Razor
{
public class RazorViewEngineOptionsTest
{
[Fact]
public void AreaViewLocationFormats_ContainsExpectedLocations()
{
// Arrange
var services = new ServiceCollection().AddOptions();
var areaViewLocations = new[]
{
"/Areas/{2}/MvcViews/{1}/{0}.cshtml",
"/Areas/{2}/MvcViews/Shared/{0}.cshtml",
"/MvcViews/Shared/{0}.cshtml"
};
var builder = new MvcBuilder(services, new ApplicationPartManager());
builder.AddRazorOptions(options =>
{
options.AreaViewLocationFormats.Clear();
foreach (var location in areaViewLocations)
{
options.AreaViewLocationFormats.Add(location);
}
});
var serviceProvider = services.BuildServiceProvider();
var accessor = serviceProvider.GetRequiredService<IOptions<RazorViewEngineOptions>>();
// Act
var formats = accessor.Value.AreaViewLocationFormats;
// Assert
Assert.Equal(areaViewLocations, formats, StringComparer.Ordinal);
}
[Fact]
public void ViewLocationFormats_ContainsExpectedLocations()
{
// Arrange
var services = new ServiceCollection().AddOptions();
var viewLocations = new[]
{
"/MvcViews/{1}/{0}.cshtml",
"/MvcViews/Shared/{0}.cshtml"
};
var builder = new MvcBuilder(services, new ApplicationPartManager());
builder.AddRazorOptions(options =>
{
options.ViewLocationFormats.Clear();
foreach (var location in viewLocations)
{
options.ViewLocationFormats.Add(location);
}
});
var serviceProvider = services.BuildServiceProvider();
var accessor = serviceProvider.GetRequiredService<IOptions<RazorViewEngineOptions>>();
// Act
var formats = accessor.Value.ViewLocationFormats;
// Assert
Assert.Equal(viewLocations, formats, StringComparer.Ordinal);
}
[Fact]
public void AddRazorOptions_ConfiguresOptionsAsExpected()
{

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Routing;
@ -364,10 +365,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
.Verifiable();
var viewEngine = new TestableRazorViewEngine(
pageFactory.Object,
GetOptionsAccessor());
viewEngine.SetLocationFormats(
new[] { "fake-path1/{1}/{0}.rzr" },
new[] { "fake-area-path/{2}/{1}/{0}.rzr" });
GetOptionsAccessor(
viewLocationFormats: new[] { "fake-path1/{1}/{0}.rzr" },
areaViewLocationFormats: new[] { "fake-area-path/{2}/{1}/{0}.rzr" }));
var context = GetActionContext(_controllerTestContext);
// Act
@ -393,10 +393,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
.Verifiable();
var viewEngine = new TestableRazorViewEngine(
pageFactory.Object,
GetOptionsAccessor());
viewEngine.SetLocationFormats(
new[] { "fake-path1/{1}/{0}.rzr" },
new[] { "fake-area-path/{2}/{1}/{0}.rzr" });
GetOptionsAccessor(
viewLocationFormats: new[] { "fake-path1/{1}/{0}.rzr" },
areaViewLocationFormats: new[] { "fake-area-path/{2}/{1}/{0}.rzr" }));
var context = GetActionContext(_areaTestContext);
// Act
@ -1462,38 +1461,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
Assert.Equal(expectedPagePath, result);
}
[Fact]
public void AreaViewLocationFormats_ContainsExpectedLocations()
{
// Arrange
var viewEngine = CreateViewEngine();
var areaViewLocations = new string[]
{
"/Areas/{2}/Views/{1}/{0}.cshtml",
"/Areas/{2}/Views/Shared/{0}.cshtml",
"/Views/Shared/{0}.cshtml"
};
// Act & Assert
Assert.Equal(areaViewLocations, viewEngine.AreaViewLocationFormats);
}
[Fact]
public void ViewLocationFormats_ContainsExpectedLocations()
{
// Arrange
var viewEngine = CreateViewEngine();
var viewLocations = new string[]
{
"/Views/{1}/{0}.cshtml",
"/Views/Shared/{0}.cshtml"
};
// Act & Assert
Assert.Equal(viewLocations, viewEngine.ViewLocationFormats);
}
[Fact]
public void GetNormalizedRouteValue_ReturnsValueFromRouteValues_IfKeyHandlingIsRequired()
{
@ -1713,15 +1680,19 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
IEnumerable<IViewLocationExpander> expanders = null)
{
pageFactory = pageFactory ?? Mock.Of<IRazorPageFactoryProvider>();
return new TestableRazorViewEngine(
pageFactory,
GetOptionsAccessor(expanders));
return new TestableRazorViewEngine(pageFactory, GetOptionsAccessor(expanders));
}
private static IOptions<RazorViewEngineOptions> GetOptionsAccessor(
IEnumerable<IViewLocationExpander> expanders = null)
IEnumerable<IViewLocationExpander> expanders = null,
IEnumerable<string> viewLocationFormats = null,
IEnumerable<string> areaViewLocationFormats = null)
{
var optionsSetup = new RazorViewEngineOptionsSetup(Mock.Of<IHostingEnvironment>());
var options = new RazorViewEngineOptions();
optionsSetup.Configure(options);
if (expanders != null)
{
foreach (var expander in expanders)
@ -1730,6 +1701,26 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
}
}
if (viewLocationFormats != null)
{
options.ViewLocationFormats.Clear();
foreach (var location in viewLocationFormats)
{
options.ViewLocationFormats.Add(location);
}
}
if (areaViewLocationFormats != null)
{
options.AreaViewLocationFormats.Clear();
foreach (var location in areaViewLocationFormats)
{
options.AreaViewLocationFormats.Add(location);
}
}
var optionsAccessor = new Mock<IOptions<RazorViewEngineOptions>>();
optionsAccessor
.SetupGet(v => v.Value)
@ -1784,9 +1775,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
private class TestableRazorViewEngine : RazorViewEngine
{
private IEnumerable<string> _viewLocationFormats;
private IEnumerable<string> _areaViewLocationFormats;
public TestableRazorViewEngine(
IRazorPageFactoryProvider pageFactory,
IOptions<RazorViewEngineOptions> optionsAccessor)
@ -1794,20 +1782,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test
{
}
public void SetLocationFormats(
IEnumerable<string> viewLocationFormats,
IEnumerable<string> areaViewLocationFormats)
{
_viewLocationFormats = viewLocationFormats;
_areaViewLocationFormats = areaViewLocationFormats;
}
public override IEnumerable<string> ViewLocationFormats =>
_viewLocationFormats != null ? _viewLocationFormats : base.ViewLocationFormats;
public override IEnumerable<string> AreaViewLocationFormats =>
_areaViewLocationFormats != null ? _areaViewLocationFormats : base.AreaViewLocationFormats;
public IMemoryCache ViewLookupCachePublic => ViewLookupCache;
}
}

View File

@ -1,12 +1,10 @@
// 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.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.PlatformAbstractions;
using Moq;
using Xunit;