diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs index d6c7cd7661..ec791aaee0 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs @@ -29,7 +29,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer _mvcOptions = mvcOptions; } - public IList GetApiResponseTypes(ControllerActionDescriptor action) + public ICollection GetApiResponseTypes(ControllerActionDescriptor action) { // We only provide response info if we can figure out a type that is a user-data type. // Void /Task object/IActionResult will result in no data. @@ -38,7 +38,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); var responseMetadataAttributes = GetResponseMetadataAttributes(action); - if (responseMetadataAttributes.Count == 0 && + if (responseMetadataAttributes.Count == 0 && action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) { // Action does not have any conventions. Use conventions on it if present. @@ -67,14 +67,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer .ToArray(); } - private IList GetApiResponseTypes( + private ICollection GetApiResponseTypes( IReadOnlyList responseMetadataAttributes, Type type) { - var results = new List(); - - // Build list of all possible return types (and status codes) for an action. - var objectTypes = new Dictionary(); + var results = new Dictionary(); // Get the content type that the action explicitly set to support. // Walk through all 'filter' attributes in order, and allow each one to see or override @@ -86,7 +83,17 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { metadataAttribute.SetContentTypes(contentTypes); - if (metadataAttribute.Type == typeof(void) && + ApiResponseType apiResponseType; + + if (metadataAttribute is IApiDefaultResponseMetadataProvider) + { + apiResponseType = new ApiResponseType + { + IsDefaultResponse = true, + Type = metadataAttribute.Type, + }; + } + else if (metadataAttribute.Type == typeof(void) && type != null && (metadataAttribute.StatusCode == StatusCodes.Status200OK || metadataAttribute.StatusCode == StatusCodes.Status201Created)) { @@ -94,20 +101,38 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a // [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred // from the return type. - objectTypes[metadataAttribute.StatusCode] = type; + apiResponseType = new ApiResponseType + { + StatusCode = metadataAttribute.StatusCode, + Type = type, + }; } else if (metadataAttribute.Type != null) { - objectTypes[metadataAttribute.StatusCode] = metadataAttribute.Type; + apiResponseType = new ApiResponseType + { + StatusCode = metadataAttribute.StatusCode, + Type = metadataAttribute.Type, + }; } + else + { + continue; + } + + results[apiResponseType.StatusCode] = apiResponseType; } } // Set the default status only when no status has already been set explicitly - if (objectTypes.Count == 0 && type != null) + if (results.Count == 0 && type != null) { - objectTypes[StatusCodes.Status200OK] = type; + results[StatusCodes.Status200OK] = new ApiResponseType + { + StatusCode = StatusCodes.Status200OK, + Type = type, + }; } if (contentTypes.Count == 0) @@ -117,25 +142,15 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); - foreach (var objectType in objectTypes) + foreach (var apiResponse in results.Values) { - if (objectType.Value == null || objectType.Value == typeof(void)) + var responseType = apiResponse.Type; + if (responseType == null || responseType == typeof(void)) { - results.Add(new ApiResponseType() - { - StatusCode = objectType.Key, - Type = objectType.Value - }); - continue; } - var apiResponseType = new ApiResponseType() - { - Type = objectType.Value, - StatusCode = objectType.Key, - ModelMetadata = _modelMetadataProvider.GetMetadataForType(objectType.Value) - }; + apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType); foreach (var contentType in contentTypes) { @@ -143,7 +158,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes( contentType, - objectType.Value); + responseType); if (formatterSupportedContentTypes == null) { @@ -152,7 +167,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer foreach (var formatterSupportedContentType in formatterSupportedContentTypes) { - apiResponseType.ApiResponseFormats.Add(new ApiResponseFormat() + apiResponse.ApiResponseFormats.Add(new ApiResponseFormat { Formatter = (IOutputFormatter)responseTypeMetadataProvider, MediaType = formatterSupportedContentType, @@ -160,11 +175,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } } } - - results.Add(apiResponseType); } - return results; + return results.Values; } private Type GetDeclaredReturnType(ControllerActionDescriptor action) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs index 9c44452961..e8c10cd3ac 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs @@ -74,7 +74,7 @@ namespace Microsoft.AspNetCore.Mvc var errorMessage = Resources.FormatApiConvention_UnsupportedAttributesOnConvention( methodDisplayName, Environment.NewLine + string.Join(Environment.NewLine, unsupportedAttributes) + Environment.NewLine, - $"{nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"); + $"{nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"); throw new ArgumentException(errorMessage, nameof(conventionType)); } @@ -83,6 +83,7 @@ namespace Microsoft.AspNetCore.Mvc private static bool IsAllowedAttribute(object attribute) { return attribute is ProducesResponseTypeAttribute || + attribute is ProducesDefaultResponseTypeAttribute || attribute is ApiConventionNameMatchAttribute; } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs new file mode 100644 index 0000000000..a841fc9f0a --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiDefaultResponseMetadataProvider.cs @@ -0,0 +1,12 @@ +// 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. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Provides a return type for all HTTP status codes that are not covered by other instances. + /// + public interface IApiDefaultResponseMetadataProvider : IApiResponseMetadataProvider + { + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs index 46b3ec6e80..281e78461f 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs @@ -8,25 +8,53 @@ namespace Microsoft.AspNetCore.Mvc { public static class DefaultApiConventions { + #region GET [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] public static void Get( [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object id) { } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Find( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) + { } + #endregion + + #region POST [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] public static void Post( [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object model) { } + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Create( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + #endregion + + #region PUT [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] public static void Put( [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] @@ -37,13 +65,47 @@ namespace Microsoft.AspNetCore.Mvc [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object model) { } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Edit( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Update( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) + { } + #endregion + + #region DELETE [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesDefaultResponseType] [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] public static void Delete( [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object id) { } + #endregion } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs index f9c9fa750b..b3d14d1be8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs @@ -88,7 +88,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal values[i] = value as string ?? Convert.ToString(value); } } - + if (cache.OrdinalEntries.TryGetValue(values, out var matchingRouteValues) || cache.OrdinalIgnoreCaseEntries.TryGetValue(values, out matchingRouteValues)) { @@ -441,7 +441,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal var hash = new HashCodeCombiner(); for (var i = 0; i < obj.Length; i++) { - hash.Add(obj[i], _valueComparer); + var o = obj[i]; + + // Route values define null and "" to be equivalent. + if (string.IsNullOrEmpty(o)) + { + o = null; + } + hash.Add(o, _valueComparer); } return hash.CombinedHash; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs new file mode 100644 index 0000000000..3022327a5b --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProducesDefaultResponseTypeAttribute.cs @@ -0,0 +1,49 @@ +// 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 Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// A filter that specifies the for all HTTP status codes that are not covered by . + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ProducesDefaultResponseTypeAttribute : Attribute, IApiDefaultResponseMetadataProvider + { + /// + /// Initializes an instance of . + /// + public ProducesDefaultResponseTypeAttribute() + : this(typeof(void)) + { + } + + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + public ProducesDefaultResponseTypeAttribute(Type type) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + } + + /// + /// Gets or sets the type of the value returned by an action. + /// + public Type Type { get; } + + /// + /// Gets or sets the HTTP status code of the response. + /// + public int StatusCode { get; } + + /// + void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) + { + // Users are supposed to use the 'Produces' attribute to set the content types that an action can support. + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs index b23063eeb0..3dda563ebb 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageBase.cs @@ -1214,6 +1214,33 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages public virtual UnauthorizedResult Unauthorized() => new UnauthorizedResult(); + /// + /// Creates a by specifying the name of a partial to render. + /// + /// The partial name. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName) + { + return Partial(viewName, model: null); + } + + /// + /// Creates a by specifying the name of a partial to render and the model object. + /// + /// The partial name. + /// The model to be passed into the partial. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName, object model) + { + ViewContext.ViewData.Model = model; + + return new PartialViewResult + { + ViewName = viewName, + ViewData = ViewContext.ViewData + }; + } + #region ViewComponentResult /// /// Creates a by specifying the name of a view component to render. diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs index 526d0fad72..17384ca1ce 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageModel.cs @@ -1614,6 +1614,33 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages public virtual UnauthorizedResult Unauthorized() => new UnauthorizedResult(); + /// + /// Creates a by specifying the name of a partial to render. + /// + /// The partial name. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName) + { + return Partial(viewName, model: null); + } + + /// + /// Creates a by specifying the name of a partial to render and the model object. + /// + /// The partial name. + /// The model to be passed into the partial. + /// The created object for the response. + public virtual PartialViewResult Partial(string viewName, object model) + { + ViewData.Model = model; + + return new PartialViewResult + { + ViewName = viewName, + ViewData = ViewData + }; + } + #region ViewComponentResult /// /// Creates a by specifying the name of a view component to render. diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs index fef6b7c0e8..617fa5d3a2 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ForAttributeName = "for"; private const string ModelAttributeName = "model"; + private const string FallbackAttributeName = "fallback-name"; private const string OptionalAttributeName = "optional"; private object _model; private bool _hasModel; @@ -82,6 +83,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(OptionalAttributeName)] public bool Optional { get; set; } + /// + /// View to lookup if the view specified by cannot be located. + /// + [HtmlAttributeName(FallbackAttributeName)] + public string FallbackName { get; set; } + /// /// A to pass into the partial view. /// @@ -104,21 +111,46 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers throw new ArgumentNullException(nameof(context)); } - var viewEngineResult = FindView(); - - if (viewEngineResult.Success) - { - var model = ResolveModel(); - var viewBuffer = new ViewBuffer(_viewBufferScope, Name, ViewBuffer.PartialViewPageSize); - using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) - { - await RenderPartialViewAsync(writer, model, viewEngineResult.View); - output.Content.SetHtmlContent(viewBuffer); - } - } - // Reset the TagName. We don't want `partial` to render. output.TagName = null; + + var result = FindView(Name); + var viewSearchedLocations = result.SearchedLocations; + var fallBackViewSearchedLocations = Enumerable.Empty(); + + if (!result.Success && !string.IsNullOrEmpty(FallbackName)) + { + result = FindView(FallbackName); + fallBackViewSearchedLocations = result.SearchedLocations; + } + + if (!result.Success) + { + if (Optional) + { + // Could not find the view or fallback view, but the partial is marked as optional. + return; + } + + var locations = Environment.NewLine + string.Join(Environment.NewLine, viewSearchedLocations); + var errorMessage = Resources.FormatViewEngine_PartialViewNotFound(Name, locations); + + if (!string.IsNullOrEmpty(FallbackName)) + { + locations = Environment.NewLine + string.Join(Environment.NewLine, result.SearchedLocations); + errorMessage += Environment.NewLine + Resources.FormatViewEngine_FallbackViewNotFound(FallbackName, locations); + } + + throw new InvalidOperationException(errorMessage); + } + + var model = ResolveModel(); + var viewBuffer = new ViewBuffer(_viewBufferScope, result.ViewName, ViewBuffer.PartialViewPageSize); + using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) + { + await RenderPartialViewAsync(writer, model, result.View); + output.Content.SetHtmlContent(viewBuffer); + } } // Internal for testing @@ -152,26 +184,19 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return ViewContext.ViewData.Model; } - private ViewEngineResult FindView() + private ViewEngineResult FindView(string partialName) { - var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, Name, isMainPage: false); + var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, partialName, isMainPage: false); var getViewLocations = viewEngineResult.SearchedLocations; if (!viewEngineResult.Success) { - viewEngineResult = _viewEngine.FindView(ViewContext, Name, isMainPage: false); + viewEngineResult = _viewEngine.FindView(ViewContext, partialName, isMainPage: false); } - if (!viewEngineResult.Success && !Optional) + if (!viewEngineResult.Success) { var searchedLocations = Enumerable.Concat(getViewLocations, viewEngineResult.SearchedLocations); - var locations = string.Empty; - if (searchedLocations.Any()) - { - locations += Environment.NewLine + string.Join(Environment.NewLine, searchedLocations); - } - - throw new InvalidOperationException( - Resources.FormatViewEngine_PartialViewNotFound(Name, locations)); + return ViewEngineResult.NotFound(partialName, searchedLocations); } return viewEngineResult; diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 93ac7fa60e..f5b5d8c690 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -206,6 +206,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers internal static string FormatPartialTagHelper_InvalidModelAttributes(object p0, object p1, object p2) => string.Format(CultureInfo.CurrentCulture, GetString("PartialTagHelper_InvalidModelAttributes"), p0, p1, p2); + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string ViewEngine_FallbackViewNotFound + { + get => GetString("ViewEngine_FallbackViewNotFound"); + } + + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string FormatViewEngine_FallbackViewNotFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ViewEngine_FallbackViewNotFound"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx index 1d6166e955..96c56dff7f 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -159,4 +159,7 @@ Cannot use '{0}' with both '{1}' and '{2}' attributes. + + The fallback partial view '{0}' was not found. The following locations were searched:{1} + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs index 7f3247deac..29543d26f1 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs @@ -72,6 +72,60 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer public Task> Get(int id) => null; } + [Fact] + public void GetApiResponseTypes_CombinesFilters() + { + // Arrange + var filterDescriptors = new[] + { + new FilterDescriptor(new ProducesResponseTypeAttribute(400), FilterScope.Global), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(object), 201), FilterScope.Controller), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 400), FilterScope.Controller), + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 201), FilterScope.Action), + new FilterDescriptor(new ProducesResponseTypeAttribute(404), FilterScope.Action), + }; + + var actionDescriptor = new ControllerActionDescriptor + { + FilterDescriptors = filterDescriptors, + MethodInfo = typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController).GetMethod(nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)), + }; + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + [Fact] public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() { @@ -159,6 +213,54 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer public Task> PostModel(int id, BaseModel model) => null; } + [Fact] + public void GetApiResponseTypes_ReturnsDefaultProblemResponse() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor( + typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), + nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[] + { + new ProducesResponseTypeAttribute(201), + new ProducesResponseTypeAttribute(404), + new ProducesDefaultResponseTypeAttribute(typeof(SerializableError)), + }); + + var provider = GetProvider(); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.True(responseType.IsDefaultResponse); + Assert.Equal(typeof(SerializableError), responseType.Type); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Collection( + responseType.ApiResponseFormats, + format => Assert.Equal("application/json", format.MediaType)); + }, + responseType => + { + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.False(responseType.IsDefaultResponse); + Assert.Empty(responseType.ApiResponseFormats); + }); + } + private static ApiResponseTypeProvider GetProvider() { var mvcOptions = new MvcOptions diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs index edc7ce7747..d6ff2a28f4 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs @@ -2,6 +2,7 @@ // 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.Authorization; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Testing; @@ -15,11 +16,10 @@ namespace Microsoft.AspNetCore.Mvc public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute() { // Arrange - var expected = $"Method {typeof(ConventionWithProducesAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + - Environment.NewLine + - typeof(ProducesAttribute).FullName + - Environment.NewLine + - $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get); + var attribute = typeof(ProducesAttribute); + + var expected = GetErrorMessage(methodName, attribute); // Act & Assert ExceptionAssert.ThrowsArgument( @@ -38,11 +38,9 @@ namespace Microsoft.AspNetCore.Mvc public void Constructor_ThrowsIfConventionMethodHasRouteAttribute() { // Arrange - var expected = $"Method {typeof(ConventionWithRouteAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + - Environment.NewLine + - typeof(HttpGetAttribute).FullName + - Environment.NewLine + - $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + var methodName = typeof(ConventionWithRouteAttribute).FullName + '.' + nameof(ConventionWithRouteAttribute.Get); + var attribute = typeof(HttpGetAttribute); + var expected = GetErrorMessage(methodName, attribute); // Act & Assert ExceptionAssert.ThrowsArgument( @@ -61,11 +59,9 @@ namespace Microsoft.AspNetCore.Mvc public void Constructor_ThrowsIfMultipleUnsupportedAttributesArePresentOnConvention() { // Arrange - var expected = $"Method {typeof(ConventionWitUnsupportedAttributes).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + - Environment.NewLine + - string.Join(Environment.NewLine, typeof(ProducesAttribute).FullName, typeof(ServiceFilterAttribute).FullName, typeof(AuthorizeAttribute).FullName) + - Environment.NewLine + - $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + var methodName = typeof(ConventionWitUnsupportedAttributes).FullName + '.' + nameof(ConventionWitUnsupportedAttributes.Get); + var attributes = new[] { typeof(ProducesAttribute), typeof(ServiceFilterAttribute), typeof(AuthorizeAttribute) }; + var expected = GetErrorMessage(methodName, attributes); // Act & Assert ExceptionAssert.ThrowsArgument( @@ -82,5 +78,14 @@ namespace Microsoft.AspNetCore.Mvc [Authorize] public static void Get() { } } + + private static string GetErrorMessage(string methodName, params Type[] attributes) + { + return $"Method {methodName} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + string.Join(Environment.NewLine, attributes.Select(a => a.FullName)) + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs index 69079bd8b0..e1290a0421 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs @@ -111,6 +111,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer Assert.True(result); Assert.Collection( conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), r => Assert.Equal(200, r.StatusCode), r => Assert.Equal(404, r.StatusCode)); } @@ -130,6 +131,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer Assert.True(result); Assert.Collection( conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), r => Assert.Equal(201, r.StatusCode), r => Assert.Equal(400, r.StatusCode)); } @@ -152,6 +154,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer Assert.True(result); Assert.Collection( conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), r => Assert.Equal(204, r.StatusCode), r => Assert.Equal(400, r.StatusCode), r => Assert.Equal(404, r.StatusCode)); @@ -175,6 +178,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer Assert.True(result); Assert.Collection( conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.IsAssignableFrom(r), r => Assert.Equal(200, r.StatusCode), r => Assert.Equal(400, r.StatusCode), r => Assert.Equal(404, r.StatusCode)); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs index a6e1b415cc..5835a579f0 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs @@ -277,6 +277,176 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Assert.Equal(expected, candidates); } + [Fact] + public void SelectCandidates_Match_CaseSensitiveMatch_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + // Example: In conventional route, one could set non-inline defaults + // new { area = "", controller = "Home", action = "Index" } + routeContext.RouteData.Values.Add("area", ""); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + var action = Assert.Single(candidates); + Assert.Same(actions[0], action); + } + + [Fact] + public void SelectCandidates_Match_CaseInsensitiveMatch_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + // Example: In conventional route, one could set non-inline defaults + // new { area = "", controller = "Home", action = "Index" } + routeContext.RouteData.Values.Add("area", ""); + routeContext.RouteData.Values.Add("controller", "HoMe"); + routeContext.RouteData.Values.Add("action", "InDeX"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + var action = Assert.Single(candidates); + Assert.Same(actions[0], action); + } + + [Fact] + public void SelectCandidates_Match_MatchesOnNull() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", null }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + // Example: In conventional route, one could set non-inline defaults + // new { area = (string)null, controller = "Foo", action = "Index" } + routeContext.RouteData.Values.Add("area", null); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + var action = Assert.Single(candidates); + Assert.Same(actions[0], action); + } + + [Fact] + public void SelectCandidates_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnEmptyString() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "foo", "" }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + // Example: In conventional route, one could set non-inline defaults + // new { area = (string)null, controller = "Home", action = "Index" } + routeContext.RouteData.Values.Add("foo", ""); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + var action = Assert.Single(candidates); + Assert.Same(actions[0], action); + } + + [Fact] + public void SelectCandidates_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnNull() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "foo", "" }, + { "controller", "Home" }, + { "action", "Index" } + }, + } + }; + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + // Example: In conventional route, one could set non-inline defaults + // new { area = (string)null, controller = "Home", action = "Index" } + routeContext.RouteData.Values.Add("foo", null); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + var action = Assert.Single(candidates); + Assert.Same(actions[0], action); + } + [Fact] public void SelectBestCandidate_AmbiguousActions_LogIsCorrect() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs index 43bd304d14..dd3c01c170 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/AntiforgeryTestHelper.cs @@ -1,10 +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 System.Net.Http; -using AngleSharp.Dom.Html; using AngleSharp.Parser.Html; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -19,27 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var parser = new HtmlParser(); var htmlDocument = parser.Parse(htmlContent); - return RetrieveAntiforgeryToken(htmlDocument); - } - - public static string RetrieveAntiforgeryToken(IHtmlDocument htmlDocument) - { - var hiddenInputs = htmlDocument.QuerySelectorAll("form input[type=hidden]"); - foreach (var input in hiddenInputs) - { - if (!input.HasAttribute("name")) - { - continue; - } - - var name = input.GetAttribute("name"); - if (name == "__RequestVerificationToken" || name == "HtmlEncode[[__RequestVerificationToken]]") - { - return input.GetAttribute("value"); - } - } - - throw new Exception($"Antiforgery token could not be located in {htmlDocument.Source.Text}."); + return htmlDocument.RetrieveAntiforgeryToken(); } public static CookieMetadata RetrieveAntiforgeryCookie(HttpResponseMessage response) diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index e575967651..9b556a42a1 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -711,7 +711,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); - Assert.Equal(2, description.SupportedResponseTypes.Count); Assert.Collection( description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), @@ -749,7 +748,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests // Assert var description = Assert.Single(result); - Assert.Equal(2, description.SupportedResponseTypes.Count); Assert.Collection( description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), @@ -1171,6 +1169,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Collection( description.SupportedResponseTypes.OrderBy(r => r.StatusCode), responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => { Assert.Equal(typeof(Product).FullName, responseType.ResponseType); Assert.Equal(200, responseType.StatusCode); @@ -1255,6 +1257,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Collection( description.SupportedResponseTypes.OrderBy(r => r.StatusCode), responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => { Assert.Equal(typeof(void).FullName, responseType.ResponseType); Assert.Equal(201, responseType.StatusCode); @@ -1283,6 +1289,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Collection( description.SupportedResponseTypes.OrderBy(r => r.StatusCode), responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => { Assert.Equal(typeof(void).FullName, responseType.ResponseType); Assert.Equal(204, responseType.StatusCode); @@ -1316,6 +1326,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Collection( description.SupportedResponseTypes.OrderBy(r => r.StatusCode), responseType => + { + Assert.True(responseType.IsDefaultResponse); + }, + responseType => { Assert.Equal(typeof(void).FullName, responseType.ResponseType); Assert.Equal(200, responseType.StatusCode); diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index f865f1bcd4..32cb029485 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -566,7 +566,7 @@ Products: Music Systems, Televisions (3)"; var document = await Client.GetHtmlDocumentAsync(url); // Assert - var banner = QuerySelector(document, ".banner"); + var banner = document.RequiredQuerySelector(".banner"); Assert.Equal("Some status message", banner.TextContent); } @@ -581,19 +581,36 @@ Products: Music Systems, Televisions (3)"; var document = await Client.GetHtmlDocumentAsync(url); // Assert - var banner = QuerySelector(document, ".banner"); + var banner = document.RequiredQuerySelector(".banner"); Assert.Empty(banner.TextContent); } - private static IElement QuerySelector(IHtmlDocument document, string selector) + [Fact] + public async Task PartialTagHelper_AllowsUsingFallback() { - var element = document.QuerySelector(selector); - if (element == null) - { - throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); - } + // Arrange + var url = "/Customer/PartialWithFallback"; - return element; + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Equal("Hello from fallback", content.TextContent); + } + + [Fact] + public async Task PartialTagHelper_AllowsUsingOptional() + { + // Arrange + var url = "/Customer/PartialWithOptional"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Empty(content.TextContent); } private static HttpRequestMessage RequestWithLocale(string url, string locale) diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs new file mode 100644 index 0000000000..b23ccfa657 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/IHtmlDocumentExtensions.cs @@ -0,0 +1,43 @@ +// 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 AngleSharp.Dom; +using AngleSharp.Dom.Html; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public static class IHtmlDocumentExtensions + { + public static IElement RequiredQuerySelector(this IHtmlDocument document, string selector) + { + var element = document.QuerySelector(selector); + if (element == null) + { + throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); + } + + return element; + } + + public static string RetrieveAntiforgeryToken(this IHtmlDocument htmlDocument) + { + var hiddenInputs = htmlDocument.QuerySelectorAll("form input[type=hidden]"); + foreach (var input in hiddenInputs) + { + if (!input.HasAttribute("name")) + { + continue; + } + + var name = input.GetAttribute("name"); + if (name == "__RequestVerificationToken" || name == "HtmlEncode[[__RequestVerificationToken]]") + { + return input.GetAttribute("value"); + } + } + + throw new Exception($"Antiforgery token could not be located in {htmlDocument.Source.Text}."); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs index d97e128727..88be27634f 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs @@ -11,7 +11,6 @@ using System.Net.Http.Headers; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Testing; using Newtonsoft.Json.Linq; @@ -144,6 +143,26 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("CustomActionResult", content); } + [Fact] + public async Task Page_Handler_ReturnPartialWithoutModel() + { + // Act + var document = await Client.GetHtmlDocumentAsync("RenderPartialWithoutModel"); + + var element = document.RequiredQuerySelector("#content"); + Assert.Equal("Welcome, Guest", element.TextContent); + } + + [Fact] + public async Task Page_Handler_ReturnPartialWithModel() + { + // Act + var document = await Client.GetHtmlDocumentAsync("RenderPartialWithModel"); + + var element = document.RequiredQuerySelector("#content"); + Assert.Equal("Welcome, Admin", element.TextContent); + } + [Fact] public async Task Page_Handler_AsyncReturnTypeImplementsIActionResult() { diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs index b3443d36c1..3decf16891 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageModelTest.cs @@ -1894,6 +1894,52 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages testPageModel.Verify(); } + [Fact] + public void PartialView_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData + } + }; + + // Act + var result = pageModel.Partial("LoginStatus"); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void PartialView_WithNameAndModel() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPageModel + { + PageContext = new PageContext + { + ViewData = viewData + } + }; + var model = new { Username = "Admin" }; + + // Act + var result = pageModel.Partial("LoginStatus", model); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Equal(model, result.Model); + Assert.Same(viewData, result.ViewData); + } + [Fact] public void ViewComponent_WithName() { diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs index 60d9cc7884..2e4e50cf91 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/PageTest.cs @@ -1697,6 +1697,52 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages Assert.Equal(statusCode, result.StatusCode); } + [Fact] + public void PartialView_WithName() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData + } + }; + + // Act + var result = pageModel.Partial("LoginStatus"); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Same(viewData, result.ViewData); + } + + [Fact] + public void PartialView_WithNameAndModel() + { + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); + var pageModel = new TestPage + { + ViewContext = new ViewContext + { + ViewData = viewData + } + }; + var model = new { Username = "Admin" }; + + // Act + var result = pageModel.Partial("LoginStatus", model); + + // Assert + Assert.NotNull(result); + Assert.Equal("LoginStatus", result.ViewName); + Assert.Equal(model, result.Model); + Assert.Same(viewData, result.ViewData); + } + [Fact] public void ViewComponent_WithName() { diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs index 21edd86f7d..489c78bd05 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -648,6 +648,198 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Empty(content); } + [Fact] + public async Task ProcessAsync_RendersMainPartial_If_FallbackIsSet_AndMainPartialIsFound() + { + // Arrange + var expected = "Hello from partial!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var fallbackView = new Mock(); + fallbackView.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write("Hello from fallback partial!"); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, fallbackView.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_IfHasFallback_Throws_When_MainPartialAndFallback_AreNotFound() + { + // Arrange + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var expected = string.Join( + Environment.NewLine, + $"The partial view '{partialName}' was not found. The following locations were searched:", + "PartialNotFound1", + "PartialNotFound2", + "PartialNotFound3", + "PartialNotFound4", + $"The fallback partial view '{fallbackName}' was not found. The following locations were searched:", + "FallbackNotFound1", + "FallbackNotFound2", + "FallbackNotFound3", + "FallbackNotFound4"); + var viewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()); + var viewContext = GetViewContext(); + + var view = Mock.Of(); + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "PartialNotFound1", "PartialNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"PartialNotFound3", $"PartialNotFound4" })); + + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "FallbackNotFound1", "FallbackNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"FallbackNotFound3", $"FallbackNotFound4" })); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + ViewData = viewData, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => tagHelper.ProcessAsync(tagHelperContext, output)); + Assert.Equal(expected, exception.Message); + } + + [Fact] + public async Task ProcessAsync_RendersFallbackView_If_MainIsNotFound_AndGetViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_RendersFallbackView_If_MainIsNotFound_AndFindViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(fallbackName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + private static ViewContext GetViewContext() { return new ViewContext( diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml new file mode 100644 index 0000000000..fc6b995ab1 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml @@ -0,0 +1,3 @@ +@page + + \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml new file mode 100644 index 0000000000..c3c8ecd2cb --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml @@ -0,0 +1,3 @@ +@page + +
\ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml new file mode 100644 index 0000000000..dee9334704 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml @@ -0,0 +1 @@ +Hello from fallback \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..a757b413b9 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cs b/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cs new file mode 100644 index 0000000000..82711aed2e --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorPagesWebSite +{ + public class RenderPartialWithModel : PageModel + { + public IActionResult OnGet() => Partial("_PartialWithModel", this); + + public string Username => "Admin"; + } +} diff --git a/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cshtml b/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cshtml new file mode 100644 index 0000000000..27d507ed75 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/RenderPartialWithModel.cshtml @@ -0,0 +1,4 @@ +@page +@model RazorPagesWebSite.RenderPartialWithModel + +

The partial will be loaded here ...

\ No newline at end of file diff --git a/test/WebSites/RazorPagesWebSite/RenderPartialWithoutModel.cshtml b/test/WebSites/RazorPagesWebSite/RenderPartialWithoutModel.cshtml new file mode 100644 index 0000000000..b817a76e67 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/RenderPartialWithoutModel.cshtml @@ -0,0 +1,5 @@ +@page + +@functions { + public IActionResult OnGet() => Partial("_PartialWithoutModel"); +} diff --git a/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithModel.cshtml b/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithModel.cshtml new file mode 100644 index 0000000000..5c8194f423 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithModel.cshtml @@ -0,0 +1,3 @@ +@model RazorPagesWebSite.RenderPartialWithModel + +Welcome, @Model.Username \ No newline at end of file diff --git a/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithoutModel.cshtml b/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithoutModel.cshtml new file mode 100644 index 0000000000..40b6bbaf56 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Views/Shared/_PartialWithoutModel.cshtml @@ -0,0 +1 @@ +Welcome, Guest \ No newline at end of file diff --git a/version.props b/version.props index 541f76d159..575dd6248e 100644 --- a/version.props +++ b/version.props @@ -1,4 +1,4 @@ - + 3.0.0 alpha1