diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/FormTagHelper.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/FormTagHelper.cs new file mode 100644 index 0000000000..c785a4c0a0 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/FormTagHelper.cs @@ -0,0 +1,132 @@ +// 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 System.Linq; +using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.AspNet.Razor.TagHelpers; + +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + /// + /// implementation targeting <form> elements. + /// + [ContentBehavior(ContentBehavior.Append)] + public class FormTagHelper : TagHelper + { + private const string RouteAttributePrefix = "route-"; + + [Activate] + private ViewContext ViewContext { get; set; } + + [Activate] + private IHtmlGenerator Generator { get; set; } + + /// + /// The name of the action method. + /// + /// + /// If value contains a '/' this will do nothing. + /// + public string Action { get; set; } + + /// + /// The name of the controller. + /// + public string Controller { get; set; } + + /// + /// The HTTP method for processing the form, either GET or POST. + /// + public string Method { get; set; } + + /// + /// Whether the anti-forgery token should be generated. Defaults to true if is not + /// a URL, false otherwise. + /// + [HtmlAttributeName("anti-forgery")] + public bool? AntiForgery { get; set; } + + /// + /// Does nothing if contains a '/'. + public override void Process(TagHelperContext context, TagHelperOutput output) + { + bool antiForgeryDefault = true; + + var routePrefixedAttributes = output.FindPrefixedAttributes(RouteAttributePrefix); + + // If Action contains a '/' it means the user is attempting to use the FormTagHelper as a normal form. + if (Action != null && Action.Contains('/')) + { + if (Controller != null || routePrefixedAttributes.Any()) + { + // We don't know how to generate a form action since a Controller attribute was also provided. + throw new InvalidOperationException( + Resources.FormatFormTagHelper_CannotDetermineAction( + "
", + nameof(Action).ToLowerInvariant(), + nameof(Controller).ToLowerInvariant(), + RouteAttributePrefix)); + } + + // User is using the FormTagHelper like a normal tag, anti-forgery default should be false to + // not force the anti-forgery token onto the user. + antiForgeryDefault = false; + + // Restore Action, Method and Route HTML attributes if they were provided, user wants non-TagHelper . + output.CopyHtmlAttribute(nameof(Action), context); + + if (Method != null) + { + output.CopyHtmlAttribute(nameof(Method), context); + } + } + else + { + var routeValues = GetRouteValues(output, routePrefixedAttributes); + var tagBuilder = Generator.GenerateForm(ViewContext, + Action, + Controller, + routeValues, + Method, + htmlAttributes: null); + + if (tagBuilder != null) + { + // We don't want to do a full merge because we want the TagHelper content to take precedence. + output.Merge(tagBuilder); + } + } + + if (AntiForgery ?? antiForgeryDefault) + { + var antiForgeryTagBuilder = Generator.GenerateAntiForgery(ViewContext); + if (antiForgeryTagBuilder != null) + { + output.Content += antiForgeryTagBuilder.ToString(TagRenderMode.SelfClosing); + } + } + } + + // TODO: We will not need this method once https://github.com/aspnet/Razor/issues/89 is completed. + private static Dictionary GetRouteValues( + TagHelperOutput output, IEnumerable> routePrefixedAttributes) + { + Dictionary routeValues = null; + if (routePrefixedAttributes.Any()) + { + // Prefixed values should be treated as bound attributes, remove them from the output. + output.RemoveRange(routePrefixedAttributes); + + // Generator.GenerateForm does not accept a Dictionary for route values. + routeValues = routePrefixedAttributes.ToDictionary( + attribute => attribute.Key.Substring(RouteAttributePrefix.Length), + attribute => (object)attribute.Value); + } + + return routeValues; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..3f07276690 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -0,0 +1,46 @@ +// +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.AspNet.Mvc.TagHelpers.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute. + /// + internal static string FormTagHelper_CannotDetermineAction + { + get { return GetString("FormTagHelper_CannotDetermineAction"); } + } + + /// + /// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute. + /// + internal static string FormatFormTagHelper_CannotDetermineAction(object p0, object p1, object p2, object p3) + { + return string.Format(CultureInfo.CurrentCulture, GetString("FormTagHelper_CannotDetermineAction"), p0, p1, p2, p3); + } + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx new file mode 100644 index 0000000000..722a84ffe9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute. + + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/FormTagHelperTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/FormTagHelperTest.cs new file mode 100644 index 0000000000..6a3a93f026 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/FormTagHelperTest.cs @@ -0,0 +1,379 @@ +// 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 System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Razor; +using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + public class FormTagHelperTest + { + [Fact] + public async Task ProcessAsync_GeneratesExpectedOutput() + { + // Arrange + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + var formTagHelper = new FormTagHelper + { + Action = "index", + Controller = "home", + Method = "post", + AntiForgery = true + }; + var tagHelperContext = new TagHelperContext( + allAttributes: new Dictionary + { + { "id", "myform" }, + { "route-foo", "bar" }, + { "action", "index" }, + { "controller", "home" }, + { "method", "post" }, + { "anti-forgery", true } + }); + var output = new TagHelperOutput( + "form", + attributes: new Dictionary + { + { "id", "myform" }, + { "route-foo", "bar" }, + }, + content: "Something"); + var urlHelper = new Mock(); + urlHelper + .Setup(mock => mock.Action(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns("home/index"); + + var htmlGenerator = new TestableHtmlGenerator(metadataProvider, urlHelper.Object); + var viewContext = TestableHtmlGenerator.GetViewContext(model: null, + htmlGenerator: htmlGenerator, + metadataProvider: metadataProvider); + var expectedContent = "Something" + htmlGenerator.GenerateAntiForgery(viewContext) + .ToString(TagRenderMode.SelfClosing); + var activator = new DefaultTagHelperActivator(); + activator.Activate(formTagHelper, viewContext); + + // Act + await formTagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + Assert.Equal(3, output.Attributes.Count); + var attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("id")); + Assert.Equal("myform", attribute.Value); + attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("method")); + Assert.Equal("post", attribute.Value); + attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("action")); + Assert.Equal("home/index", attribute.Value); + Assert.Equal(expectedContent, output.Content); + Assert.Equal("form", output.TagName); + } + + [Theory] + [InlineData(true, "")] + [InlineData(false, "")] + [InlineData(null, "")] + public async Task ProcessAsync_GeneratesAntiForgeryCorrectly(bool? antiForgery, string expectedContent) + { + // Arrange + var viewContext = CreateViewContext(); + var formTagHelper = new FormTagHelper + { + Action = "Index", + AntiForgery = antiForgery + }; + var context = new TagHelperContext( + allAttributes: new Dictionary()); + var output = new TagHelperOutput( + "form", + attributes: new Dictionary(), + content: string.Empty); + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new TagBuilder("form")); + + generator.Setup(mock => mock.GenerateAntiForgery(viewContext)) + .Returns(new TagBuilder("input")); + + SetViewContextAndGenerator(formTagHelper, viewContext, generator.Object); + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + Assert.Equal("form", output.TagName); + Assert.Empty(output.Attributes); + Assert.Equal(expectedContent, output.Content); + } + + [Fact] + public async Task ProcessAsync_BindsRouteValuesFromTagHelperOutput() + { + // Arrange + var testViewContext = CreateViewContext(); + var formTagHelper = new FormTagHelper + { + Action = "Index", + AntiForgery = false + }; + var context = new TagHelperContext( + allAttributes: new Dictionary()); + var expectedAttribute = new KeyValuePair("ROUTEE-NotRoute", "something"); + var output = new TagHelperOutput( + "form", + attributes: new Dictionary() + { + { "route-val", "hello" }, + { "roUte--Foo", "bar" } + }, + content: string.Empty); + output.Attributes.Add(expectedAttribute); + + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (viewContext, actionName, controllerName, routeValues, method, htmlAttributes) => + { + // Fixes Roslyn bug with lambdas + generator.ToString(); + + var routeValueDictionary = (Dictionary)routeValues; + + Assert.Equal(2, routeValueDictionary.Count); + var routeValue = Assert.Single(routeValueDictionary, kvp => kvp.Key.Equals("val")); + Assert.Equal("hello", routeValue.Value); + routeValue = Assert.Single(routeValueDictionary, kvp => kvp.Key.Equals("-Foo")); + Assert.Equal("bar", routeValue.Value); + }) + .Returns(new TagBuilder("form")) + .Verifiable(); + + SetViewContextAndGenerator(formTagHelper, testViewContext, generator.Object); + + // Act & Assert + await formTagHelper.ProcessAsync(context, output); + + Assert.Equal("form", output.TagName); + var attribute = Assert.Single(output.Attributes); + Assert.Equal(expectedAttribute, attribute); + Assert.Empty(output.Content); + generator.Verify(); + } + + [Fact] + public async Task ProcessAsync_CallsIntoGenerateFormWithExpectedParameters() + { + // Arrange + var viewContext = CreateViewContext(); + var formTagHelper = new FormTagHelper + { + Action = "Index", + Controller = "Home", + Method = "POST", + AntiForgery = false + }; + var context = new TagHelperContext( + allAttributes: new Dictionary()); + var output = new TagHelperOutput( + "form", + attributes: new Dictionary(), + content: string.Empty); + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm(viewContext, "Index", "Home", null, "POST", null)) + .Returns(new TagBuilder("form")) + .Verifiable(); + + SetViewContextAndGenerator(formTagHelper, + viewContext, + generator.Object); + + // Act & Assert + await formTagHelper.ProcessAsync(context, output); + generator.Verify(); + + Assert.Equal("form", output.TagName); + Assert.Empty(output.Attributes); + Assert.Empty(output.Content); + } + + [Fact] + public async Task ProcessAsync_RestoresBoundAttributesIfActionIsURL() + { + // Arrange + var formTagHelper = new FormTagHelper + { + Action = "http://www.contoso.com", + Method = "POST" + }; + var output = new TagHelperOutput("form", + attributes: new Dictionary(), + content: string.Empty); + var context = new TagHelperContext( + allAttributes: new Dictionary() + { + { "aCTiON", "http://www.contoso.com" }, + { "METhod", "POST" } + }); + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + Assert.Equal("form", output.TagName); + Assert.Equal(2, output.Attributes.Count); + var attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("aCTiON")); + Assert.Equal("http://www.contoso.com", attribute.Value); + attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("METhod")); + Assert.Equal("POST", attribute.Value); + Assert.Empty(output.Content); + } + + [Theory] + [InlineData(true, "")] + [InlineData(false, "")] + [InlineData(null, "")] + public async Task ProcessAsync_SupportsAntiForgeryIfActionIsURL(bool? antiForgery, string expectedContent) + { + // Arrange + var viewContext = CreateViewContext(); + var generator = new Mock(); + generator.Setup(mock => mock.GenerateAntiForgery(It.IsAny())) + .Returns(new TagBuilder("input")); + var formTagHelper = new FormTagHelper + { + Action = "http://www.contoso.com", + AntiForgery = antiForgery, + }; + SetViewContextAndGenerator(formTagHelper, + viewContext, + generator.Object); + var output = new TagHelperOutput("form", + attributes: new Dictionary(), + content: string.Empty); + var context = new TagHelperContext( + allAttributes: new Dictionary() + { + { "aCTiON", "http://www.contoso.com" } + }); + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + Assert.Equal("form", output.TagName); + var attribute = Assert.Single(output.Attributes); + Assert.Equal(new KeyValuePair("aCTiON", "http://www.contoso.com"), attribute); + Assert.Equal(expectedContent, output.Content); + } + + [Fact] + public async Task ProcessAsync_ThrowsIfActionIsUrlWithSpecifiedController() + { + // Arrange + var formTagHelper = new FormTagHelper + { + Action = "http://www.contoso.com", + Controller = "Home", + Method = "POST" + }; + var expectedErrorMessage = "Cannot determine an action for . A with a URL-based action " + + "must not have attributes starting with route- or a controller attribute."; + var tagHelperOutput = new TagHelperOutput( + "form", + attributes: new Dictionary(), + content: string.Empty); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await formTagHelper.ProcessAsync(context: null, output: tagHelperOutput); + }); + + Assert.Equal(expectedErrorMessage, ex.Message); + } + + [Fact] + public async Task ProcessAsync_ThrowsIfActionIsUrlWithSpecifiedRoutes() + { + // Arrange + var formTagHelper = new FormTagHelper + { + Action = "http://www.contoso.com", + Method = "POST" + }; + var expectedErrorMessage = "Cannot determine an action for . A with a URL-based action " + + "must not have attributes starting with route- or a controller attribute."; + var tagHelperOutput = new TagHelperOutput( + "form", + attributes: new Dictionary + { + { "route-foo", "bar" } + }, + content: string.Empty); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await formTagHelper.ProcessAsync(context: null, output: tagHelperOutput); + }); + + Assert.Equal(expectedErrorMessage, ex.Message); + } + + private static ViewContext CreateViewContext() + { + var actionContext = new ActionContext( + new Mock().Object, + new RouteData(), + new ActionDescriptor()); + + return new ViewContext( + actionContext, + Mock.Of(), + new ViewDataDictionary( + new DataAnnotationsModelMetadataProvider()), + new StringWriter()); + } + + private static void SetViewContextAndGenerator(ITagHelper tagHelper, + ViewContext viewContext, + IHtmlGenerator generator) + { + var tagHelperType = tagHelper.GetType(); + + tagHelperType.GetProperty("ViewContext", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(tagHelper, viewContext); + tagHelperType.GetProperty("Generator", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(tagHelper, generator); + } + } +} \ No newline at end of file