From 9468d741ee344c89ae6087b3336b258eb9cdd807 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Fri, 5 Dec 2014 11:42:30 -0800 Subject: [PATCH] Fix for #1671 - Adding [FromHeader] attribute --- .../BinderMetadata/FromHeaderAttribute.cs | 19 ++ .../BinderMetadata/IHeaderBinderMetadata.cs | 13 ++ .../Binders/HeaderModelBinder.cs | 81 ++++++++ src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs | 1 + .../ModelBindingFromHeaderTest.cs | 173 ++++++++++++++++++ .../MvcOptionSetupTest.cs | 3 +- .../Controllers/FromHeader_BlogController.cs | 75 ++++++++ 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/FromHeaderAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/IHeaderBinderMetadata.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Binders/HeaderModelBinder.cs create mode 100644 test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingFromHeaderTest.cs create mode 100644 test/WebSites/ModelBindingWebSite/Controllers/FromHeader_BlogController.cs diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/FromHeaderAttribute.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/FromHeaderAttribute.cs new file mode 100644 index 0000000000..b77d402fad --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/FromHeaderAttribute.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// can be placed on an action parameter or model property to indicate + /// that model binding should use a header value as the data source. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class FromHeaderAttribute : Attribute, IHeaderBinderMetadata, IModelNameProvider + { + /// + public string Name { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/IHeaderBinderMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/IHeaderBinderMetadata.cs new file mode 100644 index 0000000000..6954375006 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/BinderMetadata/IHeaderBinderMetadata.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// Metadata interface that indicates model binding should use a header value for + /// the data source of a property or parameter. + /// + public interface IHeaderBinderMetadata : IBinderMetadata + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/HeaderModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/HeaderModelBinder.cs new file mode 100644 index 0000000000..7c1ec4c745 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/HeaderModelBinder.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class HeaderModelBinder : MetadataAwareBinder + { + /// + protected override Task BindAsync( + [NotNull] ModelBindingContext bindingContext, + [NotNull] IHeaderBinderMetadata metadata) + { + var request = bindingContext.OperationBindingContext.HttpContext.Request; + + if (bindingContext.ModelType == typeof(string)) + { + var value = request.Headers.Get(bindingContext.ModelName); + bindingContext.Model = value; + + return Task.FromResult(true); + } + else if (typeof(IEnumerable).GetTypeInfo().IsAssignableFrom( + bindingContext.ModelType.GetTypeInfo())) + { + var values = request.Headers.GetCommaSeparatedValues(bindingContext.ModelName); + if (values != null) + { + bindingContext.Model = ConvertValuesToCollectionType(bindingContext.ModelType, values); + } + + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + private object ConvertValuesToCollectionType(Type modelType, IList values) + { + // There's a limited set of collection types we can support here. + // + // For the simple cases - choose a string[] or List if the destination type supports + // it. + // + // For more complex cases, if the destination type is a class and implements ICollection + // then activate it and add the values. + // + // Otherwise just give up. + if (typeof(List).IsAssignableFrom(modelType)) + { + return new List(values); + } + else if (typeof(string[]).IsAssignableFrom(modelType)) + { + return values.ToArray(); + } + else if ( + modelType.GetTypeInfo().IsClass && + !modelType.GetTypeInfo().IsAbstract && + typeof(ICollection).IsAssignableFrom(modelType)) + { + var result = (ICollection)Activator.CreateInstance(modelType); + foreach (var value in values) + { + result.Add(value); + } + + return result; + } + else + { + return null; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs index dc5653e263..e9e1b1bf6e 100644 --- a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs +++ b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs @@ -30,6 +30,7 @@ namespace Microsoft.AspNet.Mvc options.ModelBinders.Add(typeof(BinderTypeBasedModelBinder)); options.ModelBinders.Add(typeof(ServicesModelBinder)); options.ModelBinders.Add(typeof(BodyModelBinder)); + options.ModelBinders.Add(new HeaderModelBinder()); options.ModelBinders.Add(new TypeConverterModelBinder()); options.ModelBinders.Add(new TypeMatchModelBinder()); options.ModelBinders.Add(new CancellationTokenModelBinder()); diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingFromHeaderTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingFromHeaderTest.cs new file mode 100644 index 0000000000..d578524d07 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingFromHeaderTest.cs @@ -0,0 +1,173 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; +using Newtonsoft.Json; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class ModelBindingFromHeaderTest + { + private readonly IServiceProvider _services = TestHelper.CreateServices(nameof(ModelBindingWebSite)); + private readonly Action _app = new ModelBindingWebSite.Startup().Configure; + + // The action that this test hits will echo back the model-bound value + [Theory] + [InlineData("transactionId", "1e331f25-0869-4c87-8a94-64e6e40cb5a0")] + [InlineData("TransaCtionId", "1e331f25-0869-4c87-8a94-64e6e40cb5a0")] // Case-Insensitive + [InlineData("TransaCtionId", "1e331f25-0869-4c87-8a94-64e6e40cb5a0,abcd")] // Binding to string doesn't split values + public async Task FromHeader_BindHeader_ToString_OnParameter(string headerName, string headerValue) + { + // Arrange + var expected = headerValue; + + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Blog/BindToStringParameter"); + request.Headers.TryAddWithoutValidation(headerName, headerValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + Assert.Equal(expected, result.HeaderValue); + } + + // The action that this test hits will echo back the model-bound value + [Fact] + public async Task FromHeader_BindHeader_ToString_OnParameter_CustomName() + { + // Arrange + var expected = "1e331f25-0869-4c87-8a94-64e6e40cb5a0"; + + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Blog/BindToStringParameter/CustomName"); + request.Headers.TryAddWithoutValidation("tId", "1e331f25-0869-4c87-8a94-64e6e40cb5a0"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal(expected, result.HeaderValue); + Assert.Null(result.HeaderValues); + Assert.Empty(result.ModelStateErrors); + } + + // The action that this test hits will echo back the model-state error + [Theory] + [InlineData("transactionId1234", "1e331f25-0869-4c87-8a94-64e6e40cb5a0")] + public async Task FromHeader_BindHeader_ToString_OnParameter_NoValues(string headerName, string headerValue) + { + // Arrange + var expected = headerValue; + + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Blog/BindToStringParameter"); + request.Headers.TryAddWithoutValidation(headerName, headerValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Null(result.HeaderValue); + Assert.Null(result.HeaderValues); + + // This is a bug - the model state error key is wrong here. + var error = Assert.Single(result.ModelStateErrors); + Assert.Equal("transactionId.transactionId", error); + } + + // The action that this test hits will echo back the model-bound values + [Theory] + [InlineData("transactionIds", "1e331f25-0869-4c87-8a94-64e6e40cb5a0")] + [InlineData("transactionIds", "1e331f25-0869-4c87-8a94-64e6e40cb5a0,abcd,efg")] + public async Task FromHeader_BindHeader_ToStringArray_OnParameter(string headerName, string headerValue) + { + // Arrange + var expected = headerValue.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Blog/BindToStringArrayParameter"); + request.Headers.TryAddWithoutValidation(headerName, headerValue); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Null(result.HeaderValue); + Assert.Equal(expected, result.HeaderValues); + Assert.Empty(result.ModelStateErrors); + } + + // The action that this test hits will echo back the model-bound values + [Fact] + public async Task FromHeader_BindHeader_ToModel() + { + // Arrange + var title = "How to make really really good soup."; + var tags = new string[] { "Cooking", "Recipes", "Awesome" }; + + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Blog/BindToModel?author=Marvin"); + + request.Headers.TryAddWithoutValidation("title", title); + request.Headers.TryAddWithoutValidation("tags", string.Join(", ", tags)); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal(title, result.HeaderValue); + Assert.Equal(tags, result.HeaderValues); + Assert.Empty(result.ModelStateErrors); + } + + private class Result + { + public string HeaderValue { get; set; } + + public string[] HeaderValues { get; set; } + + public string[] ModelStateErrors { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs b/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs index 694180aee6..29a7d58c29 100644 --- a/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs +++ b/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs @@ -39,10 +39,11 @@ namespace Microsoft.AspNet.Mvc // Assert var i = 0; - Assert.Equal(10, mvcOptions.ModelBinders.Count); + Assert.Equal(11, mvcOptions.ModelBinders.Count); Assert.Equal(typeof(BinderTypeBasedModelBinder), mvcOptions.ModelBinders[i++].OptionType); Assert.Equal(typeof(ServicesModelBinder), mvcOptions.ModelBinders[i++].OptionType); Assert.Equal(typeof(BodyModelBinder), mvcOptions.ModelBinders[i++].OptionType); + Assert.Equal(typeof(HeaderModelBinder), mvcOptions.ModelBinders[i++].OptionType); Assert.Equal(typeof(TypeConverterModelBinder), mvcOptions.ModelBinders[i++].OptionType); Assert.Equal(typeof(TypeMatchModelBinder), mvcOptions.ModelBinders[i++].OptionType); Assert.Equal(typeof(CancellationTokenModelBinder), mvcOptions.ModelBinders[i++].OptionType); diff --git a/test/WebSites/ModelBindingWebSite/Controllers/FromHeader_BlogController.cs b/test/WebSites/ModelBindingWebSite/Controllers/FromHeader_BlogController.cs new file mode 100644 index 0000000000..4eb67c6a15 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Controllers/FromHeader_BlogController.cs @@ -0,0 +1,75 @@ +// 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.Linq; +using Microsoft.AspNet.Mvc; + +namespace ModelBindingWebSite.Controllers +{ + [Route("Blog")] + public class FromHeader_BlogController : Controller + { + // Echo back the header value + [HttpGet("BindToStringParameter")] + public object BindToStringParameter([FromHeader] string transactionId) + { + return new Result() + { + HeaderValue = transactionId, + ModelStateErrors = ModelState.Where(kvp => kvp.Value.Errors.Count > 0).Select(kvp => kvp.Key).ToArray(), + }; + } + + // Echo back the header values + [HttpGet("BindToStringArrayParameter")] + public object BindToStringArrayParameter([FromHeader] string[] transactionIds) + { + return new Result() + { + HeaderValues = transactionIds, + ModelStateErrors = ModelState.Where(kvp => kvp.Value.Errors.Count > 0).Select(kvp => kvp.Key).ToArray(), + }; + } + + [HttpGet("BindToStringParameter/CustomName")] + public object BindToStringParameterWithCustomName([FromHeader(Name = "tId")] string transactionId) + { + return new Result() + { + HeaderValue = transactionId, + ModelStateErrors = ModelState.Where(kvp => kvp.Value.Errors.Count > 0).Select(kvp => kvp.Key).ToArray(), + }; + } + + [HttpGet("BindToModel")] + public object BindToModel(BlogPost blogPost) + { + return new Result() + { + HeaderValue = blogPost.Title, + HeaderValues = blogPost.Tags, + ModelStateErrors = ModelState.Where(kvp => kvp.Value.Errors.Count > 0).Select(kvp => kvp.Key).ToArray(), + }; + } + + private class Result + { + public string HeaderValue { get; set; } + + public string[] HeaderValues { get; set; } + + public string[] ModelStateErrors { get; set; } + } + + public class BlogPost + { + [FromHeader] + public string Title { get; set; } + + [FromHeader] + public string[] Tags { get; set; } + + public string Author { get; set; } + } + } +} \ No newline at end of file