From 683c5bf9b3f4fc1b68dfafeec5c19d6010817a57 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 11 Mar 2014 09:00:55 -0700 Subject: [PATCH] Adding JsonInputFormatter for reading json encoded data from the request body --- .../ActionBindingContextExtensions.cs | 39 ---- .../ParameterBinding/ActionBindingContext.cs | 6 +- .../DefaultActionBindingContextProvider.cs | 8 +- .../ReflectedActionInvoker.cs | 43 +++- .../Formatters/CompositeInputFormatter.cs | 29 --- .../Formatters/IInputFormatter.cs | 20 +- .../Formatters/IInputFormatterProvider.cs | 13 ++ .../Formatters/InputFormatterContext.cs | 9 +- .../InputFormatterProviderContext.cs | 22 ++ .../Formatters/JsonInputFormatter.cs | 192 +++++++++++++++++- .../Formatters/TempInputFormatterProvider.cs | 41 ++++ .../Internal/ContentTypeHeaderValue.cs | 23 +++ .../Internal/HttpRequestExtensions.cs | 28 +++ .../Properties/Resources.Designer.cs | 14 ++ .../Resources.resx | 3 + .../ValueProviders/ValueProviderResult.cs | 46 ++--- .../project.json | 7 +- src/Microsoft.AspNet.Mvc/MvcServices.cs | 1 + .../Formatters/JsonInputFormatterTest.cs | 142 +++++++++++++ .../project.json | 1 + 20 files changed, 568 insertions(+), 119 deletions(-) delete mode 100644 src/Microsoft.AspNet.Mvc.Core/Internal/ActionBindingContextExtensions.cs delete mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/CompositeInputFormatter.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatterProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterProviderContext.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/TempInputFormatterProvider.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ContentTypeHeaderValue.cs create mode 100644 src/Microsoft.AspNet.Mvc.ModelBinding/Internal/HttpRequestExtensions.cs create mode 100644 test/Microsoft.AspNet.Mvc.ModelBinding.Test/Formatters/JsonInputFormatterTest.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Internal/ActionBindingContextExtensions.cs b/src/Microsoft.AspNet.Mvc.Core/Internal/ActionBindingContextExtensions.cs deleted file mode 100644 index 2f5a68ab9b..0000000000 --- a/src/Microsoft.AspNet.Mvc.Core/Internal/ActionBindingContextExtensions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.AspNet.Mvc.ModelBinding; - -namespace Microsoft.AspNet.Mvc.Internal -{ - public static class ActionBindingContextExtensions - { - public static InputFormatterContext CreateInputFormatterContext(this ActionBindingContext actionBindingContext, - ModelStateDictionary modelState, - ParameterDescriptor parameter) - { - var metadataProvider = actionBindingContext.MetadataProvider; - var parameterType = parameter.BodyParameterInfo.ParameterType; - var modelMetadata = metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); - return new InputFormatterContext(modelMetadata, modelState); - } - - public static ModelBindingContext CreateModelBindingContext(this ActionBindingContext actionBindingContext, - ModelStateDictionary modelState, - ParameterDescriptor parameter) - { - var metadataProvider = actionBindingContext.MetadataProvider; - var parameterType = parameter.ParameterBindingInfo.ParameterType; - var modelMetadata = metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); - - return new ModelBindingContext - { - ModelName = parameter.Name, - ModelState = modelState, - ModelMetadata = modelMetadata, - ModelBinder = actionBindingContext.ModelBinder, - ValueProvider = actionBindingContext.ValueProvider, - ValidatorProviders = actionBindingContext.ValidatorProviders, - MetadataProvider = metadataProvider, - HttpContext = actionBindingContext.ActionContext.HttpContext, - FallbackToEmptyPrefix = true - }; - } - } -} diff --git a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ActionBindingContext.cs b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ActionBindingContext.cs index 0902c0cf4e..8ecc630e10 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ActionBindingContext.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/ActionBindingContext.cs @@ -9,14 +9,14 @@ namespace Microsoft.AspNet.Mvc IModelMetadataProvider metadataProvider, IModelBinder modelBinder, IValueProvider valueProvider, - IInputFormatter inputFormatter, + IInputFormatterProvider inputFormatterProvider, IEnumerable validatorProviders) { ActionContext = context; MetadataProvider = metadataProvider; ModelBinder = modelBinder; ValueProvider = valueProvider; - InputFormatter = inputFormatter; + InputFormatterProvider = inputFormatterProvider; ValidatorProviders = validatorProviders; } @@ -28,7 +28,7 @@ namespace Microsoft.AspNet.Mvc public IValueProvider ValueProvider { get; private set; } - public IInputFormatter InputFormatter { get; private set; } + public IInputFormatterProvider InputFormatterProvider { get; private set; } public IEnumerable ValidatorProviders { get; private set; } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/DefaultActionBindingContextProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/DefaultActionBindingContextProvider.cs index 56a8d487e5..4b3f53aa29 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/DefaultActionBindingContextProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ParameterBinding/DefaultActionBindingContextProvider.cs @@ -10,19 +10,19 @@ namespace Microsoft.AspNet.Mvc private readonly IModelMetadataProvider _modelMetadataProvider; private readonly IEnumerable _modelBinders; private readonly IEnumerable _valueProviderFactories; - private readonly IEnumerable _inputFormatters; + private readonly IInputFormatterProvider _inputFormatterProvider; private readonly IEnumerable _validatorProviders; public DefaultActionBindingContextProvider(IModelMetadataProvider modelMetadataProvider, IEnumerable modelBinders, IEnumerable valueProviderFactories, - IEnumerable inputFormatters, + IInputFormatterProvider inputFormatterProvider, IEnumerable validatorProviders) { _modelMetadataProvider = modelMetadataProvider; _modelBinders = modelBinders.OrderBy(binder => binder.GetType() == typeof(ComplexModelDtoModelBinder) ? 1 : 0); _valueProviderFactories = valueProviderFactories; - _inputFormatters = inputFormatters; + _inputFormatterProvider = inputFormatterProvider; _validatorProviders = validatorProviders; } @@ -38,7 +38,7 @@ namespace Microsoft.AspNet.Mvc _modelMetadataProvider, new CompositeModelBinder(_modelBinders), new CompositeValueProvider(valueProviders), - new CompositeInputFormatter(_inputFormatters), + _inputFormatterProvider, _validatorProviders ); } diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs index 20e4279587..777b501db1 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionInvoker.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNet.DependencyInjection; using Microsoft.AspNet.Mvc.Filters; -using Microsoft.AspNet.Mvc.Internal; using Microsoft.AspNet.Mvc.ModelBinding; namespace Microsoft.AspNet.Mvc @@ -129,24 +128,46 @@ namespace Microsoft.AspNet.Mvc { var actionBindingContext = await _bindingProvider.GetActionBindingContextAsync(_actionContext); var parameters = _descriptor.Parameters; - + var metadataProvider = actionBindingContext.MetadataProvider; var parameterValues = new Dictionary(parameters.Count, StringComparer.Ordinal); - for (int i = 0; i < parameters.Count; i++) + + for (var i = 0; i < parameters.Count; i++) { var parameter = parameters[i]; if (parameter.BodyParameterInfo != null) { - var inputFormatterContext = actionBindingContext.CreateInputFormatterContext( - modelState, - parameter); - await actionBindingContext.InputFormatter.ReadAsync(inputFormatterContext); - parameterValues[parameter.Name] = inputFormatterContext.Model; + var parameterType = parameter.BodyParameterInfo.ParameterType; + var modelMetadata = metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); + var providerContext = new InputFormatterProviderContext(actionBindingContext.ActionContext.HttpContext, + modelMetadata, + modelState); + + var inputFormatter = actionBindingContext.InputFormatterProvider.GetInputFormatter(providerContext); + + + var formatterContext = new InputFormatterContext(actionBindingContext.ActionContext.HttpContext, + modelMetadata, + modelState); + await inputFormatter.ReadAsync(formatterContext); + parameterValues[parameter.Name] = formatterContext.Model; } else { - var modelBindingContext = actionBindingContext.CreateModelBindingContext( - modelState, - parameter); + var parameterType = parameter.ParameterBindingInfo.ParameterType; + var modelMetadata = metadataProvider.GetMetadataForType(modelAccessor: null, modelType: parameterType); + + var modelBindingContext = new ModelBindingContext + { + ModelName = parameter.Name, + ModelState = modelState, + ModelMetadata = modelMetadata, + ModelBinder = actionBindingContext.ModelBinder, + ValueProvider = actionBindingContext.ValueProvider, + ValidatorProviders = actionBindingContext.ValidatorProviders, + MetadataProvider = metadataProvider, + HttpContext = actionBindingContext.ActionContext.HttpContext, + FallbackToEmptyPrefix = true + }; actionBindingContext.ModelBinder.BindModel(modelBindingContext); parameterValues[parameter.Name] = modelBindingContext.Model; } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/CompositeInputFormatter.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/CompositeInputFormatter.cs deleted file mode 100644 index 6386bcfe04..0000000000 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/CompositeInputFormatter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.Mvc.ModelBinding -{ - public class CompositeInputFormatter : IInputFormatter - { - private IInputFormatter[] _bodyReaders; - - public CompositeInputFormatter(IEnumerable bodyReaders) - { - _bodyReaders = bodyReaders.ToArray(); - } - - public async Task ReadAsync(InputFormatterContext context) - { - for(int i = 0; i < _bodyReaders.Length; i++) - { - if (await _bodyReaders[i].ReadAsync(context)) - { - return true; - } - } - - return false; - } - } -} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatter.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatter.cs index d32c85a9c2..5ff702d384 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatter.cs @@ -1,9 +1,25 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; namespace Microsoft.AspNet.Mvc.ModelBinding { public interface IInputFormatter { - Task ReadAsync(InputFormatterContext context); + /// + /// Gets the mutable collection of media types supported by this instance. + /// + IList SupportedMediaTypes { get; } + + /// + /// Gets the mutable collection of character encodings supported by this + /// instance. + /// + IList SupportedEncodings { get; } + + /// + /// Called during deserialization to read an object from the request. + /// + Task ReadAsync(InputFormatterContext context); } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatterProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatterProvider.cs new file mode 100644 index 0000000000..6225be32f4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/IInputFormatterProvider.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public interface IInputFormatterProvider + { + IInputFormatter GetInputFormatter(InputFormatterProviderContext context); + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterContext.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterContext.cs index f123e66293..7556b12c18 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterContext.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterContext.cs @@ -1,15 +1,22 @@ using System; +using System.Text; +using Microsoft.AspNet.Abstractions; namespace Microsoft.AspNet.Mvc.ModelBinding { public class InputFormatterContext { - public InputFormatterContext(ModelMetadata metadata, ModelStateDictionary modelState) + public InputFormatterContext([NotNull] HttpContext httpContext, + [NotNull] ModelMetadata metadata, + [NotNull] ModelStateDictionary modelState) { + HttpContext = httpContext; Metadata = metadata; ModelState = modelState; } + public HttpContext HttpContext { get; private set; } + public ModelMetadata Metadata { get; private set; } public ModelStateDictionary ModelState { get; private set; } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterProviderContext.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterProviderContext.cs new file mode 100644 index 0000000000..bcc3f48975 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/InputFormatterProviderContext.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNet.Abstractions; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class InputFormatterProviderContext + { + public InputFormatterProviderContext([NotNull] HttpContext httpContext, + [NotNull] ModelMetadata metadata, + [NotNull] ModelStateDictionary modelState) + { + HttpContext = httpContext; + Metadata = metadata; + ModelState = modelState; + } + + public HttpContext HttpContext { get; private set; } + + public ModelMetadata Metadata { get; private set; } + + public ModelStateDictionary ModelState { get; private set; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/JsonInputFormatter.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/JsonInputFormatter.cs index 269f3077a5..def4ad4b21 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/JsonInputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/JsonInputFormatter.cs @@ -1,13 +1,201 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; +using System.Reflection; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; +using Newtonsoft.Json; namespace Microsoft.AspNet.Mvc.ModelBinding { public class JsonInputFormatter : IInputFormatter { - public Task ReadAsync(InputFormatterContext bindingContext) + private const int DefaultMaxDepth = 32; + private readonly List _supportedEncodings; + private readonly List _supportedMediaTypes; + private JsonSerializerSettings _jsonSerializerSettings; + + public JsonInputFormatter() { - return Task.FromResult(false); + _supportedMediaTypes = new List + { + "application/json", + "text/json" + }; + + _supportedEncodings = new List + { + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + new UnicodeEncoding(bigEndian: false, byteOrderMark: true, throwOnInvalidBytes: true) + }; + + _jsonSerializerSettings = new JsonSerializerSettings + { + MissingMemberHandling = MissingMemberHandling.Ignore, + + // Limit the object graph we'll consume to a fixed depth. This prevents stackoverflow exceptions + // from deserialization errors that might occur from deeply nested objects. + MaxDepth = DefaultMaxDepth, + + // Do not change this setting + // Setting this to None prevents Json.NET from loading malicious, unsafe, or security-sensitive types + TypeNameHandling = TypeNameHandling.None + }; + } + + /// + public IList SupportedMediaTypes + { + get { return _supportedMediaTypes; } + } + + /// + public IList SupportedEncodings + { + get { return _supportedEncodings; } + } + + /// + /// Gets or sets the used to configure the . + /// + public JsonSerializerSettings SerializerSettings + { + get { return _jsonSerializerSettings; } + set + { + if (value == null) + { + throw new ArgumentNullException("value"); + } + + _jsonSerializerSettings = value; + } + } + + /// + /// Gets or sets if deserialization errors are captured. When set, these errors appear in + /// the instance of . + /// + public bool CaptureDeserilizationErrors { get; set; } + + /// + public async Task ReadAsync([NotNull] InputFormatterContext context) + { + var request = context.HttpContext.Request; + if (request.ContentLength == 0) + { + var modelType = context.Metadata.ModelType; + context.Model = modelType.GetTypeInfo().IsValueType ? Activator.CreateInstance(modelType) : + null; + return ; + } + + // Get the character encoding for the content + // Never non-null since SelectCharacterEncoding() throws in error / not found scenarios + var effectiveEncoding = SelectCharacterEncoding(request.GetContentType()); + + context.Model = await ReadInternal(context, effectiveEncoding); + } + + /// + /// Called during deserialization to get the . + /// + /// The for the read. + /// The from which to read. + /// The to use when reading. + /// The used during deserialization. + public virtual JsonReader CreateJsonReader([NotNull] InputFormatterContext context, + [NotNull] Stream readStream, + [NotNull] Encoding effectiveEncoding) + { + return new JsonTextReader(new StreamReader(readStream, effectiveEncoding)); + } + + // + /// Called during deserialization to get the . + /// + /// The used during serialization and deserialization. + public virtual JsonSerializer CreateJsonSerializer() + { + return JsonSerializer.Create(SerializerSettings); + } + + private bool IsSupportedContentType(ContentTypeHeaderValue contentType) + { + return contentType != null && + _supportedMediaTypes.Contains(contentType.ContentType, StringComparer.OrdinalIgnoreCase); + } + + private Task ReadInternal(InputFormatterContext context, + Encoding effectiveEncoding) + { + var type = context.Metadata.ModelType; + var request = context.HttpContext.Request; + + using (var jsonReader = CreateJsonReader(context, request.Body, effectiveEncoding)) + { + jsonReader.CloseInput = false; + + var jsonSerializer = CreateJsonSerializer(); + + EventHandler errorHandler = null; + if (CaptureDeserilizationErrors) + { + errorHandler = (sender, e) => + { + var exception = e.ErrorContext.Error; + context.ModelState.AddModelError(e.ErrorContext.Path, e.ErrorContext.Error); + // Error must always be marked as handled + // Failure to do so can cause the exception to be rethrown at every recursive level and overflow the + // stack for x64 CLR processes + e.ErrorContext.Handled = true; + }; + jsonSerializer.Error += errorHandler; + } + + try + { + return Task.FromResult(jsonSerializer.Deserialize(jsonReader, type)); + } + finally + { + // Clean up the error handler in case CreateJsonSerializer() reuses a serializer + if (errorHandler != null) + { + jsonSerializer.Error -= errorHandler; + } + } + } + } + + private Encoding SelectCharacterEncoding(ContentTypeHeaderValue contentType) + { + if (contentType != null) + { + // Find encoding based on content type charset parameter + var charset = contentType.CharSet; + if (!string.IsNullOrWhiteSpace(contentType.CharSet)) + { + for (var i = 0; i < _supportedEncodings.Count; i++) + { + var supportedEncoding = _supportedEncodings[i]; + if (charset.Equals(supportedEncoding.WebName, StringComparison.OrdinalIgnoreCase)) + { + return supportedEncoding; + } + } + } + } + + if (_supportedEncodings.Count > 0) + { + return _supportedEncodings[0]; + } + + // No supported encoding was found so there is no way for us to start reading. + throw new InvalidOperationException(Resources.FormatMediaTypeFormatterNoEncoding(GetType().FullName)); } } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/TempInputFormatterProvider.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/TempInputFormatterProvider.cs new file mode 100644 index 0000000000..409b9845e9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Formatters/TempInputFormatterProvider.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding.Internal; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class TempInputFormatterProvider : IInputFormatterProvider + { + private readonly IInputFormatter[] _formatters; + + public TempInputFormatterProvider(IEnumerable formatters) + { + _formatters = formatters.ToArray(); + } + + public IInputFormatter GetInputFormatter(InputFormatterProviderContext context) + { + var request = context.HttpContext.Request; + var contentType = request.GetContentType(); + if (contentType == null) + { + // TODO: http exception? + throw new InvalidOperationException("400: Bad Request"); + } + + for (var i = 0; i < _formatters.Length; i++) + { + var formatter = _formatters[i]; + if (formatter.SupportedMediaTypes.Contains(contentType.ContentType, StringComparer.OrdinalIgnoreCase)) + { + return formatter; + } + } + + // TODO: Http exception + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "415: Unsupported content type {0}", contentType)); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ContentTypeHeaderValue.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ContentTypeHeaderValue.cs new file mode 100644 index 0000000000..7fcc051bb9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ContentTypeHeaderValue.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + public class ContentTypeHeaderValue + { + public ContentTypeHeaderValue([NotNull] string contentType, + string charSet) + { + ContentType = contentType; + CharSet = charSet; + } + + public string ContentType { get; private set; } + + public string CharSet { get; set; } + + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/HttpRequestExtensions.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/HttpRequestExtensions.cs new file mode 100644 index 0000000000..80672e36e9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/HttpRequestExtensions.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.AspNet.Abstractions; + +namespace Microsoft.AspNet.Mvc.ModelBinding.Internal +{ + public static class HttpRequestExtensions + { + private const string ContentTypeHeader = "Content-Type"; + private const string CharSetToken = "charset="; + + public static ContentTypeHeaderValue GetContentType(this HttpRequest httpRequest) + { + var headerValue = httpRequest.Headers[ContentTypeHeader]; + if (!string.IsNullOrEmpty(headerValue)) + { + var tokens = headerValue.Split(new[] { ';' }, 2); + string charSet = null; + if (tokens.Length > 1 && tokens[1].TrimStart().StartsWith(CharSetToken, StringComparison.OrdinalIgnoreCase)) + { + charSet = tokens[1].TrimStart().Substring(CharSetToken.Length); + } + return new ContentTypeHeaderValue(tokens[0], charSet); + + } + return null; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs index b88036994e..0ffe1d7428 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs @@ -59,6 +59,20 @@ namespace Microsoft.AspNet.Mvc.ModelBinding } /// + /// No encoding found for media type formatter '{0}'. There must be at least one supported encoding registered in order for the media type formatter to read or write content. + /// + internal static string MediaTypeFormatterNoEncoding + { + get { return GetString("MediaTypeFormatterNoEncoding"); } + } + + /// + /// No encoding found for media type formatter '{0}'. There must be at least one supported encoding registered in order for the media type formatter to read or write content. + /// + internal static string FormatMediaTypeFormatterNoEncoding(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("MediaTypeFormatterNoEncoding"), p0); + } /// Property '{0}' on type '{1}' is invalid. Value-typed properties marked as [Required] must also be marked with [DataMember(IsRequired=true)] to be recognized as required. Consider attributing the declaring type with [DataContract] and the property with [DataMember(IsRequired=true)]. /// internal static string MissingDataMemberIsRequired diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx index 201236556d..efa4969cee 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx @@ -126,6 +126,9 @@ The key is invalid JQuery syntax because it is missing a closing bracket. + + No encoding found for input formatter '{0}'. There must be at least one supported encoding registered in order for the formatter to read content. + Property '{0}' on type '{1}' is invalid. Value-typed properties marked as [Required] must also be marked with [DataMember(IsRequired=true)] to be recognized as required. Consider attributing the declaring type with [DataContract] and the property with [DataMember(IsRequired=true)]. diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs index 664bb47d91..73d4335216 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ValueProviders/ValueProviderResult.cs @@ -45,54 +45,49 @@ namespace Microsoft.AspNet.Mvc.ModelBinding return ConvertTo(type, culture: null); } - public virtual object ConvertTo(Type type, CultureInfo culture) + public virtual object ConvertTo([NotNull] Type type, CultureInfo culture) { - if (type == null) - { - throw Error.ArgumentNull("type"); - } - - TypeInfo typeInfo = type.GetTypeInfo(); - object value = RawValue; + var value = RawValue; if (value == null) { // treat null route parameters as though they were the default value for the type - return typeInfo.IsValueType ? Activator.CreateInstance(type) : null; + return type.GetTypeInfo().IsValueType ? Activator.CreateInstance(type) : + null; } - if (value.GetType().GetTypeInfo().IsAssignableFrom(typeInfo)) + if (value.GetType().IsAssignableFrom(type)) { return value; } - CultureInfo cultureToUse = culture ?? Culture; + var cultureToUse = culture ?? Culture; return UnwrapPossibleArrayType(cultureToUse, value, type); } - private static object ConvertSimpleType(CultureInfo culture, object value, TypeInfo destinationType) + private static object ConvertSimpleType(CultureInfo culture, object value, Type destinationType) { - if (value == null || value.GetType().GetTypeInfo().IsAssignableFrom(destinationType)) + if (value == null || value.GetType().IsAssignableFrom(destinationType)) { return value; } // if this is a user-input value but the user didn't type anything, return no value - string valueAsString = value as string; + var valueAsString = value as string; - if (valueAsString != null && String.IsNullOrWhiteSpace(valueAsString)) + if (valueAsString != null && string.IsNullOrWhiteSpace(valueAsString)) { return null; } - if (destinationType == typeof(int).GetTypeInfo()) + if (destinationType == typeof(int)) { return Convert.ToInt32(value); } - else if (destinationType == typeof(bool).GetTypeInfo()) + else if (destinationType == typeof(bool)) { return Boolean.Parse(value.ToString()); } - else if (destinationType == typeof(string).GetTypeInfo()) + else if (destinationType == typeof(string)) { return Convert.ToString(value); } @@ -139,25 +134,24 @@ namespace Microsoft.AspNet.Mvc.ModelBinding private static object UnwrapPossibleArrayType(CultureInfo culture, object value, Type destinationType) { // array conversion results in four cases, as below - Array valueAsArray = value as Array; + var valueAsArray = value as Array; if (destinationType.IsArray) { - Type destinationElementType = destinationType.GetElementType(); - TypeInfo destElementTypeInfo = destinationElementType.GetTypeInfo(); + var destinationElementType = destinationType.GetElementType(); if (valueAsArray != null) { // case 1: both destination + source type are arrays, so convert each element IList converted = Array.CreateInstance(destinationElementType, valueAsArray.Length); - for (int i = 0; i < valueAsArray.Length; i++) + for (var i = 0; i < valueAsArray.Length; i++) { - converted[i] = ConvertSimpleType(culture, valueAsArray.GetValue(i), destElementTypeInfo); + converted[i] = ConvertSimpleType(culture, valueAsArray.GetValue(i), destinationElementType); } return converted; } else { // case 2: destination type is array but source is single element, so wrap element in array + convert - object element = ConvertSimpleType(culture, value, destElementTypeInfo); + var element = ConvertSimpleType(culture, value, destinationElementType); IList converted = Array.CreateInstance(destinationElementType, 1); converted[0] = element; return converted; @@ -169,7 +163,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding if (valueAsArray.Length > 0) { value = valueAsArray.GetValue(0); - return ConvertSimpleType(culture, value, destinationType.GetTypeInfo()); + return ConvertSimpleType(culture, value, destinationType); } else { @@ -179,7 +173,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding } // case 4: both destination + source type are single elements, so convert - return ConvertSimpleType(culture, value, destinationType.GetTypeInfo()); + return ConvertSimpleType(culture, value, destinationType); } } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/project.json b/src/Microsoft.AspNet.Mvc.ModelBinding/project.json index 86745ff3c3..e9be59004c 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/project.json +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/project.json @@ -24,6 +24,7 @@ "System.Diagnostics.Tools": "4.0.0.0", "System.Dynamic.Runtime": "4.0.0.0", "System.Globalization": "4.0.10.0", + "System.IO": "4.0.0.0", "System.Linq": "4.0.0.0", "System.Reflection": "4.0.10.0", "System.Reflection.Emit.ILGeneration": "4.0.0.0", @@ -35,9 +36,11 @@ "System.Runtime": "4.0.20.0", "System.Runtime.Extensions": "4.0.10.0", "System.Runtime.InteropServices": "4.0.20.0", + "System.Runtime.Serialization.Primitives": "4.0.0.0", + "System.Text.Encoding": "4.0.20.0", + "System.Text.Encoding.Extensions": "4.0.10.0", "System.Threading": "4.0.0.0", - "System.Threading.Tasks": "4.0.0.0", - "System.Runtime.Serialization.Primitives": "4.0.0.0" + "System.Threading.Tasks": "4.0.0.0" } } } diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index ea23427838..688a4a0715 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -60,6 +60,7 @@ namespace Microsoft.AspNet.Mvc yield return describe.Transient(); yield return describe.Transient(); + yield return describe.Transient(); yield return describe.Transient, DefaultFilterProvider>(); diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Formatters/JsonInputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Formatters/JsonInputFormatterTest.cs new file mode 100644 index 0000000000..625dc5ec76 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Formatters/JsonInputFormatterTest.cs @@ -0,0 +1,142 @@ +#if NET45 +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNet.Abstractions; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + public class JsonInputFormatterTest + { + [Fact] + public void DefaultMediaType_ReturnsApplicationJson() + { + // Arrange + var formatter = new JsonInputFormatter(); + + // Act + var mediaType = formatter.SupportedMediaTypes[0]; + + // Assert + Assert.Equal("application/json", mediaType); + } + + public static IEnumerable JsonFormatterReadSimpleTypesData + { + get + { + yield return new object[] { "100", typeof(int), 100 }; + yield return new object[] { "'abcd'", typeof(string), "abcd" }; + yield return new object[] { "'2012-02-01 12:45 AM'", typeof(DateTime), + new DateTime(2012, 02, 01, 00, 45, 00) }; + } + } + + [Theory] + [MemberData("JsonFormatterReadSimpleTypesData")] + public async Task JsonFormatterReadsSimpleTypes(string content, Type type, object expected) + { + // Arrange + var formatter = new JsonInputFormatter(); + var contentBytes = Encoding.UTF8.GetBytes(content); + + var httpContext = GetHttpContext(contentBytes); + var modelState = new ModelStateDictionary(); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(null, type); + var context = new InputFormatterContext(httpContext, metadata, modelState); + + // Act + await formatter.ReadAsync(context); + + // Assert + Assert.Equal(expected, context.Model); + } + + [Fact] + public async Task JsonFormatterReadsComplexTypes() + { + // Arrange + var content = "{name: 'Person Name', Age: '30'}"; + var formatter = new JsonInputFormatter(); + var contentBytes = Encoding.UTF8.GetBytes(content); + + var httpContext = GetHttpContext(contentBytes); + var modelState = new ModelStateDictionary(); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(User)); + var context = new InputFormatterContext(httpContext, metadata, modelState); + + // Act + await formatter.ReadAsync(context); + + // Assert + var model = Assert.IsType(context.Model); + Assert.Equal("Person Name", model.Name); + Assert.Equal(30, model.Age); + } + + [Fact] + public async Task ReadAsync_ThrowsOnDeserializationErrors() + { + // Arrange + var content = "{name: 'Person Name', Age: 'not-an-age'}"; + var formatter = new JsonInputFormatter(); + var contentBytes = Encoding.UTF8.GetBytes(content); + + var httpContext = GetHttpContext(contentBytes); + var modelState = new ModelStateDictionary(); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(User)); + var context = new InputFormatterContext(httpContext, metadata, modelState); + + // Act and Assert + await Assert.ThrowsAsync(() => formatter.ReadAsync(context)); + } + + [Fact] + public async Task ReadAsync_AddsModelValidationErrorsToModelState_WhenCaptureErrorsIsSet() + { + // Arrange + var content = "{name: 'Person Name', Age: 'not-an-age'}"; + var formatter = new JsonInputFormatter { CaptureDeserilizationErrors = true }; + var contentBytes = Encoding.UTF8.GetBytes(content); + + var httpContext = GetHttpContext(contentBytes); + var modelState = new ModelStateDictionary(); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(User)); + var context = new InputFormatterContext(httpContext, metadata, modelState); + + // Act + await formatter.ReadAsync(context); + + // Assert + Assert.Equal("Could not convert string to decimal: not-an-age. Path 'Age', line 1, position 39.", + modelState["Age"].Errors[0].Exception.Message); + } + + private static HttpContext GetHttpContext(byte[] contentBytes, + string contentType = "application/json") + { + var request = new Mock(); + var headers = new Mock(); + headers.SetupGet(h => h["Content-Type"]).Returns(contentType); + request.SetupGet(r => r.Headers).Returns(headers.Object); + request.SetupGet(f => f.Body).Returns(new MemoryStream(contentBytes)); + + var httpContext = new Mock(); + httpContext.SetupGet(c => c.Request).Returns(request.Object); + return httpContext.Object; + } + + private sealed class User + { + public string Name { get; set; } + + public decimal Age { get; set; } + } + } +} +#endif \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json index 668e7a4374..5509daf522 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/project.json @@ -5,6 +5,7 @@ "Microsoft.AspNet.PipelineCore": "0.1-alpha-*", "Microsoft.ComponentModel.DataAnnotations" : "4.0.10.0", "Microsoft.AspNet.Mvc.ModelBinding" : "", + "Newtonsoft.Json": "5.0.8", "TestCommon" : "", "Xunit.KRunner": "0.1-alpha-*", "xunit.abstractions": "2.0.0-aspnet-*",